Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit fb08e76be29e000cd97c8616a5c4bbe218638f2c


Parents : d801d09
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-16T10:19:48-05:00

feat: integrate visualiser-wasm build and testing into the project, improve network visualization capabilities and add battery saver

Changes

65 files changed, 6917 insertions(+), 681 deletions(-)

M Taskfile.yml +58 -1
M package.json +1 -1
M vite.config.js +106 -6

Diff

diff --git a/.gitignore b/.gitignore
index 32d72c53..5362a223 100644
--- a/.gitignore
+++ b/.gitignore
@@ -142,6 +142,14 @@ meshchat-config/
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
+# visualiser-wasm (built via scripts/build-visualiser-wasm.mjs / task build:visualiser-wasm)
+meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm
+meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js
+
+.gocache/
+.gotmp/
+.vitest-cache/
+
.hypothesis
.hypothesis/

diff --git a/Taskfile.yml b/Taskfile.yml
index 6808a4ae..b9c17283 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -390,10 +390,67 @@ tasks:
build:frontend:
aliases: [build:fe]
desc: Build frontend assets
- deps: [deps:frontend]
+ deps: [deps:frontend, build:visualiser-wasm]
cmds:
- "{{.NPM}} run build-frontend"
+ build:visualiser-wasm:
+ aliases: [build:viz-wasm]
+ desc: Build Go network visualiser WASM into frontend public vendor/
+ cmds:
+ - node scripts/build-visualiser-wasm.mjs
+
+ test:visualiser-wasm:
+ aliases: [test:viz-wasm]
+ desc: Run Go unit tests for visualiser-wasm
+ dir: visualiser-wasm
+ env:
+ GOCACHE: ../.gocache
+ GOTMPDIR: ../.gotmp
+ cmds:
+ - mkdir -p ../.gocache ../.gotmp
+ - go test ./internal/...
+
+ test:visualiser-wasm:race:
+ aliases: [test:viz-wasm:race]
+ desc: Run visualiser-wasm tests with the race detector
+ dir: visualiser-wasm
+ env:
+ GOCACHE: ../.gocache
+ GOTMPDIR: ../.gotmp
+ CGO_ENABLED: "1"
+ cmds:
+ - mkdir -p ../.gocache ../.gotmp
+ - go test -race -count=1 ./internal/...
+
+ test:visualiser-wasm:fuzz:
+ aliases: [test:viz-wasm:fuzz]
+ desc: Run short visualiser-wasm fuzz targets
+ dir: visualiser-wasm
+ env:
+ GOCACHE: ../.gocache
+ GOTMPDIR: ../.gotmp
+ cmds:
+ - mkdir -p ../.gocache ../.gotmp
+ - go test ./internal/hashpos -fuzz=FuzzAngle01 -fuzztime=5s
+ - go test ./internal/filter -fuzz=FuzzPathHashesWithinHopFilter -fuzztime=5s
+ - go test ./internal/filter -fuzz=FuzzMatchesSearch -fuzztime=3s
+ - go test ./internal/icon -fuzz=FuzzDedupeQueueEntries -fuzztime=5s
+ - go test ./internal/lod -fuzz=FuzzLevelFromScale -fuzztime=3s
+ - go test ./internal/lod -fuzz=FuzzComputeUpdates -fuzztime=5s
+ - go test ./internal/graph -fuzz=FuzzBuildPathGraph -fuzztime=8s
+
+ bench:visualiser-wasm:
+ aliases: [bench:viz-wasm]
+ desc: Run visualiser-wasm benchmarks with allocation reporting
+ dir: visualiser-wasm
+ env:
+ GOCACHE: ../.gocache
+ GOTMPDIR: ../.gotmp
+ cmds:
+ - mkdir -p ../.gocache ../.gotmp
+ - go test ./internal/... -run='^$' -bench=. -benchmem -count=1
+
build:wheel:
desc: Build Python wheel package
deps: [install]

diff --git a/eslint.config.mjs b/eslint.config.mjs
index c6dbc1b9..1eb95077 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -48,6 +48,8 @@ export default [
__APP_BUILD_TIME__: "readonly",
__MICRON_WASM_SRI_WASM__: "readonly",
__MICRON_WASM_SRI_EXEC__: "readonly",
+ __VISUALISER_WASM_SRI_WASM__: "readonly",
+ __VISUALISER_WASM_SRI_EXEC__: "readonly",
axios: "readonly",
Codec2Lib: "readonly",
Codec2MicrophoneRecorder: "readonly",

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 5e430c12..5d2959c9 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index a72a3e79..6d927ec6 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -5943,6 +5943,91 @@ class ReticulumMeshChat:
},
)
+ @routes.post("/api/v1/reticulum/interfaces/bitrates")
+ async def reticulum_interfaces_bitrates(request):
+ """Set forced bitrate (bps) on named interfaces and optionally reload RNS."""
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"message": "Invalid JSON"}, status=400)
+ if not isinstance(data, dict):
+ return web.json_response({"message": "Invalid JSON object"}, status=400)
+
+ bitrates = data.get("bitrates")
+ if not isinstance(bitrates, dict) or not bitrates:
+ return web.json_response(
+ {"message": "bitrates object is required"},
+ status=422,
+ )
+
+ reload_stack = bool(data.get("reload", False))
+ interfaces = self._get_interfaces_section()
+ interfaces_before_write = self._get_interfaces_snapshot()
+ updated = []
+ missing = []
+ for raw_name, raw_bps in bitrates.items():
+ name = InterfaceEditor.sanitize_interface_section_name(str(raw_name))
+ if not name or name not in interfaces:
+ missing.append(str(raw_name))
+ continue
+ details = interfaces[name]
+ if raw_bps is None or raw_bps == "":
+ details.pop("bitrate", None)
+ else:
+ try:
+ bps = int(raw_bps)
+ except (TypeError, ValueError):
+ return web.json_response(
+ {"message": f"Invalid bitrate for {name}"},
+ status=422,
+ )
+ if bps < 0:
+ return web.json_response(
+ {"message": f"Bitrate must be >= 0 for {name}"},
+ status=422,
+ )
+ details["bitrate"] = str(bps)
+ updated.append(name)
+
+ if not updated and missing:
+ return web.json_response(
+ {"message": "No matching interfaces", "missing": missing},
+ status=404,
+ )
+
+ if updated and not self._write_reticulum_config(
+ rollback_interfaces=interfaces_before_write
+ ):
+ return web.json_response(
+ {"message": "Failed to write Reticulum config"},
+ status=500,
+ )
+
+ reloaded = False
+ if reload_stack and updated:
+ try:
+ await self.reload_reticulum()
+ reloaded = True
+ except Exception as e:
+ return web.json_response(
+ {
+ "message": f"Bitrates saved but RNS reload failed: {e}",
+ "updated": updated,
+ "missing": missing,
+ "reloaded": False,
+ },
+ status=500,
+ )
+
+ return web.json_response(
+ {
+ "message": "Interface bitrates updated",
+ "updated": updated,
+ "missing": missing,
+ "reloaded": reloaded,
+ },
+ )
+
# fetch community interfaces
@routes.get("/api/v1/community-interfaces")
async def community_interfaces(request):
@@ -7437,6 +7522,16 @@ class ReticulumMeshChat:
except Exception:
return None
+ def _safe_resource_breakdown():
+ try:
+ from meshchatx.src.backend.process_resource_breakdown import (
+ build_resource_breakdown,
+ )
+
+ return build_resource_breakdown(process)
+ except Exception:
+ return []
+
def _safe_net_io():
try:
return psutil.net_io_counters()
@@ -7454,6 +7549,7 @@ class ReticulumMeshChat:
memory_info = _safe_memory_info()
process_usage = _safe_process_usage()
battery_usage = _safe_battery_usage()
+ resource_breakdown = _safe_resource_breakdown()
net_io = _safe_net_io()
def _safe_database_path():
@@ -7684,6 +7780,7 @@ class ReticulumMeshChat:
"create_time": process_usage.get("create_time"),
"cpu_time_seconds": process_usage.get("cpu_time_seconds"),
},
+ "resource_breakdown": resource_breakdown,
"battery_usage": battery_usage,
"network_stats": {
"bytes_sent": net_io.bytes_sent,

diff --git a/meshchatx/src/backend/process_resource_breakdown.py b/meshchatx/src/backend/process_resource_breakdown.py
new file mode 100644
index 00000000..82f08053
--- /dev/null
+++ b/meshchatx/src/backend/process_resource_breakdown.py
@@ -0,0 +1,94 @@
+# SPDX-License-Identifier: 0BSD
+"""Process RSS/CPU breakdown helpers for About usage insights."""
+
+from __future__ import annotations
+
+
+def _safe_child_name(proc) -> str:
+ try:
+ name = proc.name()
+ if name:
+ return str(name)[:64]
+ except Exception:
+ pass
+ try:
+ return f"pid:{proc.pid}"
+ except Exception:
+ return "child"
+
+
+def build_resource_breakdown(process, *, max_children: int = 8) -> list[dict]:
+ """Return process and child RSS/CPU rows sorted by RSS descending.
+
+ Values are best-effort. Restricted hosts (Android Landlock) may return
+ only the parent row or an empty list.
+ """
+ if process is None:
+ return []
+
+ rows: list[dict] = []
+
+ def add_row(label: str, proc) -> None:
+ rss = None
+ cpu = None
+ try:
+ rss = int(proc.memory_info().rss)
+ except Exception:
+ rss = None
+ try:
+ # Non-blocking sample. First call after create may be 0.0.
+ cpu = float(proc.cpu_percent(interval=None))
+ except Exception:
+ cpu = None
+ if rss is None and cpu is None:
+ return
+ rows.append(
+ {
+ "name": label,
+ "rss": rss,
+ "cpu_percent": cpu,
+ }
+ )
+
+ add_row("backend", process)
+
+ try:
+ children = list(process.children(recursive=True))
+ except Exception:
+ children = []
+
+ # Prefer largest children so About can show a useful top consumer.
+ scored = []
+ for child in children:
+ try:
+ scored.append((int(child.memory_info().rss), child))
+ except Exception:
+ try:
+ scored.append((0, child))
+ except Exception:
+ continue
+ scored.sort(key=lambda item: item[0], reverse=True)
+
+ for _, child in scored[: max(0, int(max_children))]:
+ add_row(f"child:{_safe_child_name(child)}", child)
+
+ rows.sort(key=lambda row: int(row.get("rss") or 0), reverse=True)
+ return rows
+
+
+def top_by_rss(rows: list[dict] | None) -> dict | None:
+ if not rows:
+ return None
+ best = max(rows, key=lambda row: int(row.get("rss") or 0))
+ if best.get("rss") is None:
+ return None
+ return best
+
+
+def top_by_cpu(rows: list[dict] | None) -> dict | None:
+ if not rows:
+ return None
+ scored = [row for row in rows if row.get("cpu_percent") is not None]
+ if not scored:
+ return None
+ return max(scored, key=lambda row: float(row.get("cpu_percent") or 0.0))

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index aa0fc840..bcf225ff 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -611,6 +611,11 @@ import { handleLxmIngestUriResult } from "../js/ingestUriResultNavigation.js";
import { applyRelayShareLink, parseMeshchatRelayUri } from "../js/relayLinkUtils.js";
import logoUrl from "../assets/images/logo.png";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../js/browserLayoutStore";
+import {
+ applyBackgroundPollInterval,
+ BATTERY_SAVER_CHANGED_EVENT,
+ loadBatterySaverPrefs,
+} from "../js/settings/batterySaverPrefs.js";
export default {
name: "App",
@@ -961,20 +966,48 @@ export default {
this.updateTelephoneStatus();
this.updatePropagationNodeStatus();
- this.reloadInterval = setInterval(() => {
- this.updateTelephoneStatus();
- this.updatePropagationNodeStatus();
- }, 1000);
- this.appInfoInterval = setInterval(() => {
- this.getAppInfo();
- }, 15000);
- this.unreadCountInterval = setInterval(() => {
- this.updateUnreadConversationsCount();
- this.updateRelayChatUnreadCount();
- }, 5000);
+ GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this.onBatterySaverPrefsChangedShell);
+ this.startShellPollIntervals();
this.updateUnreadConversationsCount();
this.updateRelayChatUnreadCount();
},
+ startShellPollIntervals() {
+ clearInterval(this.reloadInterval);
+ clearInterval(this.appInfoInterval);
+ clearInterval(this.unreadCountInterval);
+ this.reloadInterval = null;
+ this.appInfoInterval = null;
+ this.unreadCountInterval = null;
+ if (!this.shellRunning) {
+ return;
+ }
+ const prefs = loadBatterySaverPrefs();
+ this.reloadInterval = setInterval(
+ () => {
+ this.updateTelephoneStatus();
+ this.updatePropagationNodeStatus();
+ },
+ applyBackgroundPollInterval(1000, prefs)
+ );
+ this.appInfoInterval = setInterval(
+ () => {
+ this.getAppInfo();
+ },
+ applyBackgroundPollInterval(15000, prefs)
+ );
+ this.unreadCountInterval = setInterval(
+ () => {
+ this.updateUnreadConversationsCount();
+ this.updateRelayChatUnreadCount();
+ },
+ applyBackgroundPollInterval(5000, prefs)
+ );
+ },
+ onBatterySaverPrefsChangedShell() {
+ if (this.shellRunning) {
+ this.startShellPollIntervals();
+ }
+ },
stopShell() {
if (!this.shellRunning) {
return;
@@ -986,6 +1019,7 @@ export default {
this.appInfoInterval = null;
clearInterval(this.unreadCountInterval);
this.unreadCountInterval = null;
+ GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this.onBatterySaverPrefsChangedShell);
WebSocketConnection.off("disconnected", this.onWsShellDisconnected);
WebSocketConnection.off("connected", this.onWsShellConnected);
this.unregisterShellWsHandlers();

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index e5ebad71..2343373e 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -459,104 +459,6 @@
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
</div>
- <div
- v-if="appInfo.memory_usage || appInfo.battery_usage"
- class="flex flex-col gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800"
- >
- <span class="text-[10px] font-black text-cyan-600 uppercase tracking-wider">{{
- $t("about.usage_insights")
- }}</span>
- <div v-if="batteryUsageLabel" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.app_battery_use")
- }}</span>
- <span
- class="font-mono text-xs font-bold tabular-nums shrink-0"
- :class="batteryUsageToneClass"
- :title="$t('about.app_battery_use_hint')"
- >
- {{ batteryUsageLabel }}
- </span>
- </div>
- <div v-if="batteryUsageShareLabel" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.app_battery_share")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- batteryUsageShareLabel
- }}</span>
- </div>
- <div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.memory_rss")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- formatBytes(appInfo.memory_usage.rss || 0)
- }}</span>
- </div>
- <div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.virtual_memory")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- formatBytes(appInfo.memory_usage.vms || 0)
- }}</span>
- </div>
- <div
- v-if="appInfo.memory_usage && appInfo.memory_usage.cpu_percent != null"
- class="flex items-center justify-between gap-3"
- >
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.process_cpu")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- formatCpuPercent(appInfo.memory_usage.cpu_percent)
- }}</span>
- </div>
- <div
- v-if="appInfo.memory_usage && appInfo.memory_usage.num_threads != null"
- class="flex items-center justify-between gap-3"
- >
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.process_threads")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- appInfo.memory_usage.num_threads
- }}</span>
- </div>
- <div v-if="processUptimeLabel" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.process_uptime")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- processUptimeLabel
- }}</span>
- </div>
- <div v-if="memoryPressureLabel" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.memory_pressure")
- }}</span>
- <span class="font-mono text-xs font-bold tabular-nums">{{
- memoryPressureLabel
- }}</span>
- </div>
- <div v-if="showHostBattery" class="flex items-center justify-between gap-3">
- <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
- $t("about.env_host_battery")
- }}</span>
- <span
- class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
- :class="batteryStatusToneClass"
- >
- <v-icon
- v-if="batteryStatus"
- :icon="'mdi-' + batteryStatusIcon"
- size="14"
- ></v-icon>
- {{ batteryStatusLabel }}
- </span>
- </div>
- </div>
<div
v-if="isLinuxHost && appInfo.landlock_requested !== undefined"
class="flex flex-col gap-1"
@@ -611,6 +513,174 @@
</div>
</div>
+ <!-- Usage and battery -->
+ <div
+ v-if="
+ appInfo &&
+ (appInfo.memory_usage || appInfo.battery_usage || showHostBattery || batterySaverPrefs)
+ "
+ class="about-section"
+ >
+ <div
+ class="text-xs font-black text-cyan-600 uppercase tracking-[0.2em] mb-6 flex items-center gap-2"
+ >
+ <v-icon icon="mdi-gauge" size="14"></v-icon>
+ {{ $t("about.usage_insights") }}
+ </div>
+ <div
+ class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 text-sm min-w-0 rounded-xl border border-gray-200/60 dark:border-zinc-800/80 p-4 sm:bg-black/2 dark:sm:bg-white/2"
+ >
+ <div class="flex items-center justify-between gap-3 sm:col-span-2 lg:col-span-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.battery_saver")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold tabular-nums shrink-0"
+ :class="
+ batterySaverPrefs.enabled
+ ? 'text-emerald-600 dark:text-emerald-400'
+ : 'opacity-70'
+ "
+ >
+ {{
+ batterySaverPrefs.enabled
+ ? $t("about.battery_saver_on")
+ : $t("about.battery_saver_off")
+ }}
+ </span>
+ </div>
+ <div
+ v-if="batterySaverActiveMeasures.length"
+ class="sm:col-span-2 lg:col-span-3 space-y-1.5"
+ >
+ <div class="text-[10px] font-semibold uppercase tracking-wider opacity-70">
+ {{ $t("about.battery_saver_measures") }}
+ </div>
+ <ul
+ class="text-xs grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 list-disc list-inside opacity-90"
+ >
+ <li v-for="measure in batterySaverActiveMeasures" :key="measure">
+ {{ $t(`about.battery_saver_measure.${measure}`) }}
+ </li>
+ </ul>
+ </div>
+ <div v-if="topMemoryConsumerLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.top_memory_consumer")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold tabular-nums text-right shrink-0 max-w-[70%]"
+ :title="topMemoryConsumerLabel"
+ >
+ {{ topMemoryConsumerLabel }}
+ </span>
+ </div>
+ <div v-if="topCpuConsumerLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.top_cpu_consumer")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold tabular-nums text-right shrink-0 max-w-[70%]"
+ :title="topCpuConsumerLabel"
+ >
+ {{ topCpuConsumerLabel }}
+ </span>
+ </div>
+ <div v-if="batteryUsageLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.app_battery_use")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold tabular-nums shrink-0"
+ :class="batteryUsageToneClass"
+ :title="$t('about.app_battery_use_hint')"
+ >
+ {{ batteryUsageLabel }}
+ </span>
+ </div>
+ <div v-if="batteryUsageShareLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.app_battery_share")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ batteryUsageShareLabel
+ }}</span>
+ </div>
+ <div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.memory_rss")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ formatBytes(appInfo.memory_usage.rss || 0)
+ }}</span>
+ </div>
+ <div v-if="appInfo.memory_usage" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.virtual_memory")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ formatBytes(appInfo.memory_usage.vms || 0)
+ }}</span>
+ </div>
+ <div
+ v-if="appInfo.memory_usage && appInfo.memory_usage.cpu_percent != null"
+ class="flex items-center justify-between gap-3"
+ >
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.process_cpu")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ formatCpuPercent(appInfo.memory_usage.cpu_percent)
+ }}</span>
+ </div>
+ <div
+ v-if="appInfo.memory_usage && appInfo.memory_usage.num_threads != null"
+ class="flex items-center justify-between gap-3"
+ >
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.process_threads")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{
+ appInfo.memory_usage.num_threads
+ }}</span>
+ </div>
+ <div v-if="processUptimeLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.process_uptime")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{ processUptimeLabel }}</span>
+ </div>
+ <div v-if="pathTableSizeLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.path_table")
+ }}</span>
+ <span class="font-mono text-xs font-bold tabular-nums">{{ pathTableSizeLabel }}</span>
+ </div>
+ <div v-if="memoryPressureLabel" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.memory_pressure")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold tabular-nums"
+ :class="memoryPressureToneClass"
+ >{{ memoryPressureLabel }}</span
+ >
+ </div>
+ <div v-if="showHostBattery" class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-semibold uppercase tracking-wider opacity-70">{{
+ $t("about.env_host_battery")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
+ :class="batteryStatusToneClass"
+ >
+ <v-icon v-if="batteryStatus" :icon="'mdi-' + batteryStatusIcon" size="14"></v-icon>
+ {{ batteryStatusLabel }}
+ </span>
+ </div>
+ </div>
+ </div>
+
<!-- Dependency Chain -->
<div v-if="appInfo" class="about-section">
<div
@@ -1180,6 +1250,13 @@ import {
getDeviceBatteryStatus,
isNativeBatteryStatus,
} from "../../js/deviceBattery.js";
+import {
+ activeBatterySaverMeasures,
+ applyBackgroundPollInterval,
+ BATTERY_SAVER_CHANGED_EVENT,
+ loadBatterySaverPrefs,
+} from "../../js/settings/batterySaverPrefs.js";
+import { mergeResourceBreakdown, topResourceByCpu, topResourceByRss } from "../../js/resourceBreakdown.js";
export default {
name: "AboutPage",
components: {},
@@ -1200,6 +1277,7 @@ export default {
healthLoading: false,
electronMemoryUsage: null,
batteryStatus: null,
+ batterySaverPrefs: loadBatterySaverPrefs(),
backupInProgress: false,
backupMessage: "",
backupError: "",
@@ -1284,22 +1362,53 @@ export default {
batteryUsageToneClass() {
return appBatteryUsageToneClass(this.appInfo?.battery_usage);
},
+ batterySaverActiveMeasures() {
+ return activeBatterySaverMeasures(this.batterySaverPrefs);
+ },
+ resourceBreakdownRows() {
+ return mergeResourceBreakdown(this.appInfo?.resource_breakdown, this.electronMemoryUsage);
+ },
+ topMemoryConsumerLabel() {
+ const top = topResourceByRss(this.resourceBreakdownRows);
+ if (!top) return "";
+ return `${top.name} (${this.formatBytes(top.rss || 0)})`;
+ },
+ topCpuConsumerLabel() {
+ const top = topResourceByCpu(this.resourceBreakdownRows);
+ if (!top) return "";
+ return `${top.name} (${this.formatCpuPercent(top.cpu_percent)})`;
+ },
processUptimeLabel() {
return formatProcessUptime(this.appInfo?.memory_usage?.create_time);
},
+ pathTableSizeLabel() {
+ const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
+ const total = this.appInfo?.reticulum_stats?.total_paths;
+ const pathSize =
+ cleanup && typeof cleanup === "object" && cleanup.path_table_size != null
+ ? cleanup.path_table_size
+ : total;
+ if (pathSize == null) {
+ return null;
+ }
+ return this.$t("about.path_table_count", { count: pathSize });
+ },
memoryPressureLabel() {
const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
if (!cleanup || typeof cleanup !== "object") {
- return null;
+ return this.$t("about.memory_pressure_normal");
}
if (cleanup.sqlite_relaxed) {
return this.$t("about.memory_pressure_relaxed");
}
- const pathSize = cleanup.path_table_size;
- if (pathSize != null) {
- return this.$t("about.memory_pressure_paths", { count: pathSize });
+ return this.$t("about.memory_pressure_normal");
+ },
+ memoryPressureToneClass() {
+ const cleanup = this.appInfo?.reticulum_stats?.memory_cleanup;
+ if (cleanup && typeof cleanup === "object" && cleanup.sqlite_relaxed) {
+ return "text-amber-600 dark:text-amber-400";
}
- return null;
+ return "opacity-70";
},
batteryStatusLabel() {
if (!this.batteryStatus || !this.batteryStatus.supported) {
@@ -1371,13 +1480,12 @@ export default {
this.getDatabaseHealth();
this.listSnapshots();
this.listAutoBackups();
- // Update stats every 5 seconds
- this.updateInterval = setInterval(() => {
- this.getAppInfo();
- }, 5000);
- this.healthInterval = setInterval(() => {
- this.getDatabaseHealth();
- }, 30000);
+ this._batterySaverPrefsHandler = (prefs) => {
+ this.batterySaverPrefs = prefs || loadBatterySaverPrefs();
+ this.restartAboutPollIntervals();
+ };
+ GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ this.restartAboutPollIntervals();
},
beforeUnmount() {
if (this.updateInterval) {
@@ -1386,8 +1494,32 @@ export default {
if (this.healthInterval) {
clearInterval(this.healthInterval);
}
+ if (this._batterySaverPrefsHandler) {
+ GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ }
},
methods: {
+ restartAboutPollIntervals() {
+ if (this.updateInterval) {
+ clearInterval(this.updateInterval);
+ }
+ if (this.healthInterval) {
+ clearInterval(this.healthInterval);
+ }
+ const prefs = this.batterySaverPrefs || loadBatterySaverPrefs();
+ this.updateInterval = setInterval(
+ () => {
+ this.getAppInfo();
+ },
+ applyBackgroundPollInterval(5000, prefs)
+ );
+ this.healthInterval = setInterval(
+ () => {
+ this.getDatabaseHealth();
+ },
+ applyBackgroundPollInterval(30000, prefs)
+ );
+ },
async listSnapshots() {
try {
const response = await window.api.get("/api/v1/database/snapshots", {

diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 5a1c87b4..f0583fa1 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -730,6 +730,8 @@ import ToastUtils from "../../js/ToastUtils";
import GlobalState from "../../js/GlobalState";
import Toggle from "../forms/Toggle.vue";
import BundledDocsHint from "./BundledDocsHint.vue";
+import GlobalEmitter from "../../js/GlobalEmitter";
+import { BATTERY_SAVER_CHANGED_EVENT, loadBatterySaverPrefs } from "../../js/settings/batterySaverPrefs.js";
export default {
name: "InterfacesPage",
@@ -934,6 +936,9 @@ export default {
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
+ if (this._batterySaverPrefsHandler) {
+ GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ }
},
mounted() {
try {
@@ -953,16 +958,30 @@ export default {
this.loadDiscoveryConfig();
this.loadDiscoveredInterfaces();
- // update info every few seconds
- this.reloadInterval = setInterval(() => {
- this.updateInterfaceStats();
- }, 1000);
-
- this.discoveryInterval = setInterval(() => {
- this.loadDiscoveredInterfaces();
- }, 5000);
+ this._batterySaverPrefsHandler = () => {
+ this.startInterfacePollIntervals();
+ };
+ GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ this.startInterfacePollIntervals();
},
methods: {
+ startInterfacePollIntervals() {
+ clearInterval(this.reloadInterval);
+ clearInterval(this.discoveryInterval);
+ this.reloadInterval = null;
+ this.discoveryInterval = null;
+ const prefs = loadBatterySaverPrefs();
+ const statsMs =
+ prefs.enabled && prefs.reduceInterfacesDiscovery ? prefs.interfacesStatsPollSeconds * 1000 : 1000;
+ const discoveryMs =
+ prefs.enabled && prefs.reduceInterfacesDiscovery ? prefs.interfacesDiscoveryPollSeconds * 1000 : 5000;
+ this.reloadInterval = setInterval(() => {
+ this.updateInterfaceStats();
+ }, statsMs);
+ this.discoveryInterval = setInterval(() => {
+ this.loadDiscoveredInterfaces();
+ }, discoveryMs);
+ },
relaunch() {
ElectronUtils.relaunch();
},

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 33e687d8..bc4082c2 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -26,6 +26,8 @@
:online-interface-count="onlineInterfaces.length"
:offline-interface-count="offlineInterfaces.length"
:search-query="searchQuery"
+ :engine-mode="engineMode"
+ :fps="fps"
@update:is-showing-controls="isShowingControls = $event"
@update:auto-reload="autoReload = $event"
@update:enable-physics="enablePhysics = $event"
@@ -55,10 +57,29 @@ import {
VIZ_ANNOUNCE_ASPECTS,
VIZ_ANNOUNCE_SOFT_CAP,
VIZ_PATH_TABLE_SOFT_CAP,
+ buildFullGraph,
+ computeLodUpdates,
dedupeIconQueueEntries,
+ lodLevelFromScale,
pathHashesWithinHopFilter,
pickAdaptiveFetchConcurrency,
+ settleLayout,
+ warmVisualiserWasm,
} from "../../js/networkVisualiserPerf.js";
+import { isVisualiserWasmReady } from "../../js/VisualiserWasmLoader.js";
+import { loadVisualiserCache, saveVisualiserCache } from "../../js/networkVisualiserCache.js";
+import {
+ BATTERY_SAVER_CHANGED_EVENT,
+ effectiveVisualiserReloadMs,
+ loadBatterySaverPrefs,
+ saveBatterySaverPrefs,
+} from "../../js/settings/batterySaverPrefs.js";
+import {
+ loadVisualiserDisplayPrefs,
+ persistVisualiserAutoReload,
+ persistVisualiserLiveLayout,
+ VISUALISER_DISPLAY_PREFS_CHANGED,
+} from "../../js/settings/settingsVisualiserPrefs.js";
const HOP_MAX_FILTER_STORAGE_KEY = "meshchatx.visualiser.maxHops";
@@ -140,17 +161,18 @@ export default {
NetworkVisualiserLegend,
},
data() {
+ const displayPrefs = loadVisualiserDisplayPrefs();
return {
reticulumLogoPath: "/assets/images/reticulum_logo_512.png",
config: null,
- autoReload: false,
+ autoReload: displayPrefs.autoReload,
reloadInterval: null,
isShowingControls: true,
isUpdating: false,
isLoading: false,
- enablePhysics: true,
- showDisabledInterfaces: false,
- showDiscoveredInterfaces: false,
+ enablePhysics: displayPrefs.enablePhysics,
+ showDisabledInterfaces: displayPrefs.showDisabledInterfaces,
+ showDiscoveredInterfaces: displayPrefs.showDiscoveredInterfaces,
loadingStatus: "Initializing...",
loadedNodesCount: 0,
totalNodesToLoad: 0,
@@ -185,6 +207,15 @@ export default {
lodRafId: null,
vizRunGeneration: 0,
physicsPausedForDrag: false,
+ engineMode: "checking",
+ fps: 0,
+ fpsRafId: null,
+ fpsFrameCount: 0,
+ fpsLastSampleMs: 0,
+ cachedPositions: {},
+ batterySaverPrefs: loadBatterySaverPrefs(),
+ suppressLiveLayoutPersist: false,
+ suppressAutoReloadPersist: false,
};
},
computed: {
@@ -200,11 +231,30 @@ export default {
},
watch: {
autoReload(val) {
+ if (!this.suppressAutoReloadPersist) {
+ // Silent persist: do not rebuild the graph for toggle-only changes.
+ persistVisualiserAutoReload(val === true, { emit: false });
+ }
if (val) {
this.manualUpdate();
}
+ this.restartAutoReloadInterval();
},
- enablePhysics() {
+ enablePhysics(val) {
+ if (!this.suppressLiveLayoutPersist) {
+ // Silent persist: emitting prefs-changed would reprocess the graph
+ // and re-settle node positions (looks like a reset).
+ persistVisualiserLiveLayout(val === true, { emit: false });
+ // Explicit Live Layout on should win over battery-saver override.
+ if (val === true) {
+ const prefs = loadBatterySaverPrefs();
+ if (prefs.enabled && prefs.disableVisualiserLiveLayout) {
+ this.batterySaverPrefs = saveBatterySaverPrefs({
+ disableVisualiserLiveLayout: false,
+ });
+ }
+ }
+ }
this.refreshPhysicsEnabled();
},
searchQuery() {
@@ -231,7 +281,10 @@ export default {
this.iconQueue = [];
this.iconQueueGeneration += 1;
if (this._visualiserPrefsHandler) {
- GlobalEmitter.off("visualiser-display-prefs-changed", this._visualiserPrefsHandler);
+ GlobalEmitter.off(VISUALISER_DISPLAY_PREFS_CHANGED, this._visualiserPrefsHandler);
+ }
+ if (this._batterySaverPrefsHandler) {
+ GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
clearInterval(this.reloadInterval);
if (this.hopFilterDebounceTimer) {
@@ -246,6 +299,7 @@ export default {
cancelAnimationFrame(this.lodRafId);
this.lodRafId = null;
}
+ this.stopFpsMeter();
if (this.network) {
this.network.destroy();
}
@@ -279,16 +333,126 @@ export default {
this._visualiserPrefsHandler = () => {
this.loadVisualiserDisplayPrefs();
+ this.applyBatterySaverVisualiserPrefs();
+ if (this.network) {
+ this.processVisualization();
+ }
+ };
+ GlobalEmitter.on(VISUALISER_DISPLAY_PREFS_CHANGED, this._visualiserPrefsHandler);
+
+ this._batterySaverPrefsHandler = (prefs) => {
+ this.batterySaverPrefs = prefs || loadBatterySaverPrefs();
+ this.applyBatterySaverVisualiserPrefs();
+ this.restartAutoReloadInterval();
if (this.network) {
this.processVisualization();
}
};
- GlobalEmitter.on("visualiser-display-prefs-changed", this._visualiserPrefsHandler);
+ GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
this.loadVisualiserDisplayPrefs();
+ this.applyBatterySaverVisualiserPrefs();
+ this.resolveEngineMode();
+ this.startFpsMeter();
this.init();
},
methods: {
+ applyBatterySaverVisualiserPrefs() {
+ const prefs = this.batterySaverPrefs || loadBatterySaverPrefs();
+ if (!prefs.enabled) {
+ return;
+ }
+ if (prefs.disableVisualiserDiscovery) {
+ this.showDiscoveredInterfaces = false;
+ }
+ if (prefs.hideOfflineInterfaces) {
+ this.showDisabledInterfaces = false;
+ }
+ if (prefs.disableVisualiserLiveLayout) {
+ // Session override only. Do not overwrite the stored Live Layout pref.
+ this.suppressLiveLayoutPersist = true;
+ this.enablePhysics = false;
+ this.suppressLiveLayoutPersist = false;
+ }
+ if (prefs.visualiserReloadSeconds === 0) {
+ this.suppressAutoReloadPersist = true;
+ this.autoReload = false;
+ this.suppressAutoReloadPersist = false;
+ }
+ },
+ restartAutoReloadInterval() {
+ clearInterval(this.reloadInterval);
+ this.reloadInterval = null;
+ const ms = effectiveVisualiserReloadMs(15000, this.batterySaverPrefs || loadBatterySaverPrefs());
+ if (ms == null) {
+ return;
+ }
+ this.reloadInterval = setInterval(this.onAutoReload, ms);
+ },
+ resolveEngineMode() {
+ warmVisualiserWasm()
+ .then((ok) => {
+ this.engineMode = ok && isVisualiserWasmReady() ? "wasm" : "fallback";
+ })
+ .catch(() => {
+ this.engineMode = "fallback";
+ });
+ },
+ startFpsMeter() {
+ this.stopFpsMeter();
+ this.fpsFrameCount = 0;
+ this.fpsLastSampleMs = performance.now();
+ const tick = (now) => {
+ this.fpsFrameCount += 1;
+ const elapsed = now - this.fpsLastSampleMs;
+ if (elapsed >= 1000) {
+ this.fps = Math.round((this.fpsFrameCount * 1000) / elapsed);
+ this.fpsFrameCount = 0;
+ this.fpsLastSampleMs = now;
+ if (this.engineMode === "checking" && isVisualiserWasmReady()) {
+ this.engineMode = "wasm";
+ }
+ }
+ this.fpsRafId = requestAnimationFrame(tick);
+ };
+ this.fpsRafId = requestAnimationFrame(tick);
+ },
+ stopFpsMeter() {
+ if (this.fpsRafId != null) {
+ cancelAnimationFrame(this.fpsRafId);
+ this.fpsRafId = null;
+ }
+ },
+ snapshotNodePositions() {
+ const out = {};
+ if (!this.network || typeof this.network.getPositions !== "function") {
+ return out;
+ }
+ const ids = this.nodes.getIds();
+ const snap = this.network.getPositions(ids);
+ if (!snap) return out;
+ for (const id of ids) {
+ const p = snap[id];
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ out[id] = { x: p.x, y: p.y };
+ }
+ }
+ return out;
+ },
+ async persistVisualiserCache() {
+ const identityHash = this.config?.identity_hash;
+ if (!identityHash) return;
+ const positions = this.snapshotNodePositions();
+ this.cachedPositions = { ...this.cachedPositions, ...positions };
+ await saveVisualiserCache({
+ identityHash,
+ pathTable: this.pathTable,
+ announces: this.announces,
+ positions: this.cachedPositions,
+ pathSoftCap: VIZ_PATH_TABLE_SOFT_CAP,
+ announceSoftCap: VIZ_ANNOUNCE_SOFT_CAP,
+ });
+ },
onUserHopMaxFilterChange(v) {
this.hopMaxFilter = v;
writeStoredHopMaxFilter(v);
@@ -413,7 +577,7 @@ export default {
this.loadingStatus = "Loading announces...";
await this.fetchAnnouncesForHashes(missing);
}
- if (reset && needed.length > 0) {
+ if (needed.length > 0) {
const neededSet = new Set(needed);
for (const hash of Object.keys(this.announces)) {
if (!neededSet.has(hash)) {
@@ -579,27 +743,81 @@ export default {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},
loadVisualiserDisplayPrefs() {
- try {
- if (typeof localStorage !== "undefined") {
- if (localStorage.getItem("meshchatx.visualiser.showDisabledInterfaces") === "true") {
- this.showDisabledInterfaces = true;
- }
- if (localStorage.getItem("meshchatx.visualiser.showDiscoveredInterfaces") === "true") {
- this.showDiscoveredInterfaces = true;
- }
- }
- } catch {
- /* localStorage unavailable */
- }
+ const p = loadVisualiserDisplayPrefs();
+ this.showDisabledInterfaces = p.showDisabledInterfaces;
+ this.showDiscoveredInterfaces = p.showDiscoveredInterfaces;
+ this.enablePhysics = p.enablePhysics;
+ this.autoReload = p.autoReload;
},
refreshPhysicsEnabled() {
if (!this.network) return;
if (this.physicsPausedForDrag) return;
+ // Freeze current coordinates before stopping the solver so peers
+ // stay where the live layout left them.
+ if (!this.enablePhysics) {
+ this.snapshotNetworkPositions();
+ }
this.network.setOptions({
physics: { enabled: this.enablePhysics },
edges: { smooth: VIZ_EDGE_SMOOTH },
});
},
+ snapshotNetworkPositions() {
+ if (!this.network || typeof this.network.getPositions !== "function") return;
+ const ids = this.nodes.getIds();
+ if (!ids.length) return;
+ const snap = this.network.getPositions(ids) || {};
+ const updates = [];
+ for (const id of ids) {
+ const p = snap[id];
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ this.cachedPositions[id] = { x: p.x, y: p.y };
+ updates.push({ id, x: p.x, y: p.y });
+ }
+ }
+ if (updates.length > 0) {
+ this.nodes.update(updates);
+ }
+ },
+ resettleLayoutFromNetwork() {
+ if (!this.network || typeof this.network.getPositions !== "function") return;
+ const ids = this.nodes.getIds();
+ if (!ids.length) return;
+ const snap = this.network.getPositions(ids) || {};
+ const layoutNodes = [];
+ for (const id of ids) {
+ const node = this.nodes.get(id);
+ const p = snap[id] || {};
+ layoutNodes.push({
+ id,
+ x: Number.isFinite(p.x) ? p.x : node?.x || 0,
+ y: Number.isFinite(p.y) ? p.y : node?.y || 0,
+ mass: id === "me" ? 4 : node?.group === "interface" ? 2.5 : 1,
+ fixed: id === "me",
+ });
+ }
+ const layoutEdges = this.edges.get().map((e) => ({
+ from: e.from,
+ to: e.to,
+ length: e.width >= 2.5 ? 150 : 180,
+ }));
+ const settled = settleLayout({ nodes: layoutNodes, edges: layoutEdges, iterations: 0 });
+ const positions = settled?.positions || {};
+ const updates = [];
+ for (const id of ids) {
+ const p = positions[id];
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ updates.push({ id, x: p.x, y: p.y });
+ this.cachedPositions[id] = { x: p.x, y: p.y };
+ }
+ }
+ if (updates.length > 0) {
+ this.nodes.update(updates);
+ }
+ if (this.enablePhysics) {
+ this.refreshPhysicsEnabled();
+ }
+ },
pickStablePosition(id, posById, initialFn) {
const prev = posById[id];
if (prev && Number.isFinite(prev.x) && Number.isFinite(prev.y)) {
@@ -730,8 +948,8 @@ export default {
await this.manualUpdate();
- // auto reload
- this.reloadInterval = setInterval(this.onAutoReload, 15000);
+ // auto reload (interval respects battery saver)
+ this.restartAutoReloadInterval();
},
async manualUpdate() {
if (this.isLoading) return;
@@ -766,30 +984,15 @@ export default {
if (!this.network) return;
if (typeof this.network.getScale !== "function") return;
const scale = this.network.getScale();
- let newLOD = "high";
- if (scale < 0.2) {
- newLOD = "low";
- } else if (scale < 0.5) {
- newLOD = "medium";
- }
+ const newLOD = lodLevelFromScale(scale);
if (this.currentLOD === newLOD) return;
this.currentLOD = newLOD;
// Only mutate nodes whose LOD props actually change (avoids O(N)
// DataSet churn + full redraw when zooming across thresholds).
- const allNodes = this.nodes.get();
- const updates = [];
- for (const node of allNodes) {
- const next = this.getNodeLODProps(node, newLOD);
- const shapeChanged = next.shape != null && next.shape !== node.shape;
- const sizeChanged = next.size != null && next.size !== node.size;
- const fontSize = next.font?.size;
- const fontChanged = fontSize != null && fontSize !== (node.font?.size ?? null);
- if (shapeChanged || sizeChanged || fontChanged) {
- updates.push(next);
- }
- }
+ const isDarkMode = document.documentElement.classList.contains("dark");
+ const updates = computeLodUpdates(this.nodes.get(), newLOD, isDarkMode);
if (updates.length > 0) {
this.nodes.update(updates);
}
@@ -878,21 +1081,43 @@ export default {
this.currentBatch = 0;
this.totalBatches = 0;
- await Promise.all([
- this.getConfig(),
- this.getInterfaceStats(),
- this.getConversations(),
- this.getDiscoveredInterfaces(),
- ]);
+ await this.getConfig();
+ if (this.abortController.signal.aborted) return;
+
+ const identityHash = this.config?.identity_hash;
+ let paintedFromCache = false;
+ if (identityHash) {
+ const cached = await loadVisualiserCache(identityHash);
+ if (cached?.pathTable?.length) {
+ this.pathTable = cached.pathTable;
+ this.announces = { ...(cached.announces || {}) };
+ this.cachedPositions = { ...(cached.positions || {}) };
+ paintedFromCache = true;
+ }
+ }
+
+ await Promise.all([this.getInterfaceStats(), this.getConversations(), this.getDiscoveredInterfaces()]);
if (this.abortController.signal.aborted) return;
+ if (paintedFromCache && this.pathTable.length > 0) {
+ this.loadingStatus = "Restoring cached graph...";
+ await this.processVisualization();
+ if (this.abortController.signal.aborted) return;
+ }
+
this.loadingStatus = "Fetching network data...";
await this.getPathTableBatch();
if (this.abortController.signal.aborted) return;
- await this.ensureAnnouncesForPathHashes({ reset: true });
+ /*
+ * Keep cached announces for known hashes and only fetch missing ones
+ * so reopen stays fast while still picking up newly seen nodes.
+ */
+ await this.ensureAnnouncesForPathHashes({ reset: false });
if (this.abortController.signal.aborted) return;
await this.processVisualization();
+ if (this.abortController.signal.aborted) return;
+ await this.persistVisualiserCache();
},
async processVisualization() {
await new Promise((r) => {
@@ -914,14 +1139,12 @@ export default {
this.iconQueue = [];
/*
- * Pause physics for the duration of the bulk update. Running the
- * force-directed solver between chunks just churns the layout for
- * a partial graph and pegs the main thread on slow CPUs. We
- * restore the user's physics preference at the end so the final
- * layout still settles naturally.
+ * Pause vis-network physics for the bulk update. WASM may settle
+ * starting positions, then Live Layout re-enables JS physics so the
+ * graph keeps moving without requiring a drag.
*/
const physicsWasOn = this.network && this.enablePhysics;
- if (physicsWasOn) {
+ if (this.network) {
this.network.setOptions({
physics: { enabled: false },
edges: { smooth: VIZ_EDGE_SMOOTH },
@@ -939,9 +1162,9 @@ export default {
edges: { smooth: VIZ_EDGE_SMOOTH },
});
}
- if (physicsWasOn && this.network && !this.physicsPausedForDrag) {
+ if (this.network && !this.physicsPausedForDrag) {
this.network.setOptions({
- physics: { enabled: this.enablePhysics },
+ physics: { enabled: Boolean(physicsWasOn || this.enablePhysics) },
edges: { smooth: VIZ_EDGE_SMOOTH },
});
}
@@ -957,7 +1180,11 @@ export default {
const processedEdgeIds = new Set();
const posById = {};
-
+ for (const [id, p] of Object.entries(this.cachedPositions || {})) {
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ posById[id] = { x: p.x, y: p.y };
+ }
+ }
const existingNodeIds = this.nodes.getIds();
if (this.network) {
const snap = this.network.getPositions(existingNodeIds);
@@ -972,169 +1199,79 @@ export default {
}
const isDarkMode = document.documentElement.classList.contains("dark");
- const fontColor = isDarkMode ? "#ffffff" : "#000000";
-
- const searchLower = this.searchQuery.toLowerCase();
- const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
-
- const meLabel = this.config?.display_name ?? "Local Node";
- if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
- const mp = this.pickStablePosition("me", posById, () => ({ x: 0, y: 0 }));
- let meNode = {
- id: "me",
- group: "me",
- size: 50,
- _originalSize: 50,
- shape: "circularImage",
- _originalShape: "circularImage",
- image: this.reticulumLogoPath,
- label: meLabel,
- title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
- color: this.nodeColor("#3b82f6", isDarkMode ? "#1e40af" : "#eff6ff"),
- font: { color: fontColor, size: 16, bold: true },
- x: mp.x,
- y: mp.y,
+ this.totalNodesToLoad = this.pathTable.length;
+ this.loadedNodesCount = 0;
+ this.loadingStatus = "Building graph...";
+
+ const announcePayload = {};
+ for (const [hash, announce] of Object.entries(this.announces || {})) {
+ if (!announce) continue;
+ announcePayload[hash] = {
+ destination_hash: announce.destination_hash,
+ aspect: announce.aspect,
+ display_name: announce.display_name,
+ custom_display_name: announce.custom_display_name,
+ identity_hash: announce.identity_hash,
+ last_seen: announce.updated_at
+ ? Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))
+ : "",
};
- meNode = { ...meNode, ...this.getNodeLODProps(meNode, this.currentLOD) };
- this.nodes.update([meNode]);
- processedNodeIds.add("me");
+ }
+ const conversationPayload = {};
+ for (const [hash, conv] of Object.entries(this.conversations || {})) {
+ if (!conv?.lxmf_user_icon) continue;
+ conversationPayload[hash] = { lxmf_user_icon: conv.lxmf_user_icon };
}
- const interfaceNodes = [];
- const interfaceEdges = [];
- const ifaceEntries = [];
- const radius = 400;
+ const searchLower = this.searchQuery.toLowerCase();
+ const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
- for (let idx = 0; idx < this.interfaces.length; idx++) {
- const entry = this.interfaces[idx];
- if (!this.showDisabledInterfaces && !entry.status) {
- continue;
- }
+ const interfacesPayload = [];
+ for (const entry of this.interfaces) {
+ if (!this.showDisabledInterfaces && !entry.status) continue;
let label = entry.interface_name ?? entry.name;
if (entry.type === "LocalServerInterface" || entry.parent_interface_name != null) {
label = entry.name;
}
- if (matchesSearch(label) || matchesSearch(entry.name)) {
- ifaceEntries.push({ entry, label });
- }
- }
-
- const nIface = ifaceEntries.length;
- for (let j = 0; j < nIface; j++) {
- const { entry, label } = ifaceEntries[j];
- const angle = nIface > 0 ? (j / nIface) * 2 * Math.PI : 0;
- const initialX = Math.cos(angle) * radius;
- const initialY = Math.sin(angle) * radius;
- const pos = this.pickStablePosition(entry.name, posById, () => ({ x: initialX, y: initialY }));
-
- let interfaceNode = {
- id: entry.name,
- group: "interface",
- label: label,
+ if (!matchesSearch(label) && !matchesSearch(entry.name)) continue;
+ interfacesPayload.push({
+ name: entry.name,
+ label,
title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
- size: 35,
- _originalSize: 35,
- shape: "circularImage",
- _originalShape: "circularImage",
- image: entry.status
- ? "/assets/images/network-visualiser/interface_connected.png"
- : "/assets/images/network-visualiser/interface_disconnected.png",
- color: this.nodeColor(entry.status ? "#10b981" : "#ef4444", isDarkMode ? "#064e3b" : "#ecfdf5"),
- font: { color: fontColor, size: 12, bold: true },
- x: pos.x,
- y: pos.y,
- };
- interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) };
- interfaceNodes.push(interfaceNode);
- processedNodeIds.add(entry.name);
-
- const edgeId = `me~${entry.name}`;
- interfaceEdges.push({
- id: edgeId,
- from: "me",
- to: entry.name,
- color: entry.status
- ? this.directEdgeColor(isDarkMode)
- : {
- color: isDarkMode ? "#f87171" : "#ef4444",
- opacity: 1,
- },
- // Solid wider stroke (no arrows), since arrows are redrawn every
- // physics frame and dominate canvas cost on large meshes.
- width: 3,
- length: 200,
- hidden: false,
+ online: !!entry.status,
});
- processedEdgeIds.add(edgeId);
}
- /*
- * interface-stats can be empty while the path table still names
- * interfaces on every hop. vis-network drops any edge whose from/to
- * node does not exist, so synthesize interface nodes from the path
- * table whenever stats did not already create them.
- */
- const pathOnlyInterfaces = [...this.pathTableInterfaceNames()].filter(
- (name) => !processedNodeIds.has(name)
- );
- const nPathIface = pathOnlyInterfaces.length;
- for (let j = 0; j < nPathIface; j++) {
- const ifaceName = pathOnlyInterfaces[j];
- if (!matchesSearch(ifaceName) && !matchesSearch(this.interfaceDisplayLabel(ifaceName))) {
- continue;
- }
- const angle = nPathIface > 0 ? (j / nPathIface) * 2 * Math.PI : 0;
- const initialX = Math.cos(angle) * radius;
- const initialY = Math.sin(angle) * radius;
- const pos = this.pickStablePosition(ifaceName, posById, () => ({ x: initialX, y: initialY }));
- const label = this.interfaceDisplayLabel(ifaceName);
- let pathIfaceNode = {
- id: ifaceName,
- group: "interface",
- label,
- title: `${ifaceName}\nState: Active (path table)\nUsed as next-hop for known routes`,
- size: 35,
- _originalSize: 35,
- shape: "circularImage",
- _originalShape: "circularImage",
- image: "/assets/images/network-visualiser/interface_connected.png",
- color: this.nodeColor("#10b981", isDarkMode ? "#064e3b" : "#ecfdf5"),
- font: { color: fontColor, size: 12, bold: true },
- x: pos.x,
- y: pos.y,
- };
- pathIfaceNode = { ...pathIfaceNode, ...this.getNodeLODProps(pathIfaceNode, this.currentLOD) };
- interfaceNodes.push(pathIfaceNode);
- processedNodeIds.add(ifaceName);
-
- if (processedNodeIds.has("me")) {
- const edgeId = `me~${ifaceName}`;
- interfaceEdges.push({
- id: edgeId,
- from: "me",
- to: ifaceName,
- color: this.directEdgeColor(isDarkMode),
- width: 3,
- length: 200,
- hidden: false,
- });
- processedEdgeIds.add(edgeId);
- }
+ const saver = this.batterySaverPrefs || loadBatterySaverPrefs();
+ if (
+ saver.enabled &&
+ saver.maxVisualiserInterfaces > 0 &&
+ interfacesPayload.length > saver.maxVisualiserInterfaces
+ ) {
+ interfacesPayload.sort((a, b) => Number(b.online) - Number(a.online));
+ interfacesPayload.length = saver.maxVisualiserInterfaces;
}
- if (interfaceNodes.length > 0) this.nodes.update(interfaceNodes);
- if (interfaceEdges.length > 0) this.edges.update(interfaceEdges);
+ const seenIface = new Set(interfacesPayload.map((i) => i.name));
+ const pathOnlyPayload = [];
+ for (const name of this.pathTableInterfaceNames()) {
+ if (seenIface.has(name)) continue;
+ if (!matchesSearch(name) && !matchesSearch(this.interfaceDisplayLabel(name))) continue;
+ pathOnlyPayload.push({
+ name,
+ label: this.interfaceDisplayLabel(name),
+ title: `${name}\nState: Active (path table)\nUsed as next-hop for known routes`,
+ online: true,
+ });
+ }
- const discoveredNodes = [];
- const discoveredEdges = [];
+ const discoveredPayload = [];
if (this.showDiscoveredInterfaces) {
const activeEndpoints = new Set();
for (const a of this.discoveredActive) {
const aHost = a.target_host || a.remote || a.listen_ip;
const aPort = a.target_port || a.listen_port;
- if (aHost && aPort != null) {
- activeEndpoints.add(`${aHost}:${aPort}`);
- }
+ if (aHost && aPort != null) activeEndpoints.add(`${aHost}:${aPort}`);
}
for (const disc of this.discoveredInterfaces) {
const discId = `discovered~${disc.discovery_hash || disc.name}`;
@@ -1146,254 +1283,224 @@ export default {
) {
continue;
}
-
- if (this.hopFilterMax != null && disc.hops != null && disc.hops > this.hopFilterMax) {
- continue;
- }
-
const isConnected =
disc.reachable_on != null &&
disc.port != null &&
activeEndpoints.has(`${disc.reachable_on}:${disc.port}`);
-
- const angle = Math.random() * 2 * Math.PI;
- const dist = 800 + Math.random() * 200;
- const dp = this.pickStablePosition(discId, posById, () => ({
- x: Math.cos(angle) * dist,
- y: Math.sin(angle) * dist,
- }));
- let discNode = {
+ discoveredPayload.push({
id: discId,
- group: "discovered",
label: discLabel,
title: `Discovered: ${discLabel}\nType: ${disc.type || "Unknown"}\nHops: ${disc.hops ?? "?"}\nStatus: ${isConnected ? "Connected" : disc.status || "Available"}${disc.reachable_on ? `\nAddress: ${disc.reachable_on}:${disc.port}` : ""}`,
- size: 25,
- _originalSize: 25,
+ connected: isConnected,
+ hops: disc.hops ?? null,
+ });
+ }
+ }
+
+ const meLabel = this.config?.display_name ?? "Local Node";
+ const fullReq = {
+ me_label: meLabel,
+ me_title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
+ me_image: this.reticulumLogoPath,
+ identity_hash: this.config?.identity_hash ?? "",
+ interfaces: interfacesPayload,
+ path_only_interfaces: pathOnlyPayload,
+ discovered: discoveredPayload,
+ path_table: this.pathTable,
+ announces: announcePayload,
+ conversations: conversationPayload,
+ icon_cache: this.iconCache,
+ positions: posById,
+ hop_max: this.hopFilterMax,
+ search: this.searchQuery,
+ dark_mode: isDarkMode,
+ lod: this.currentLOD,
+ aspects: VIZ_ANNOUNCE_ASPECTS,
+ queue_icons: this.currentLOD !== "low",
+ icon_generation: this.iconQueueGeneration,
+ show_discovered: this.showDiscoveredInterfaces,
+ };
+
+ let graph;
+ if (isVisualiserWasmReady()) {
+ graph = buildFullGraph(fullReq);
+ } else {
+ // JS fallback keeps the previous path-only WASM/JS builder plus local shells.
+ graph = buildFullGraph(fullReq);
+ if (!graph.nodes?.some((n) => n.id === "me")) {
+ // buildFullGraph JS fallback only emits path nodes when WASM is down
+ }
+ }
+ if (!isCurrentRun()) return;
+
+ let graphNodes = Array.isArray(graph.nodes) ? graph.nodes : [];
+ let graphEdges = Array.isArray(graph.edges) ? graph.edges : [];
+
+ // When WASM full-graph is unavailable, synthesize me/ifaces/discovered in JS.
+ if (!isVisualiserWasmReady()) {
+ const localNodes = [];
+ const localEdges = [];
+ if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
+ const mp = this.pickStablePosition("me", posById, () => ({ x: 0, y: 0 }));
+ let meNode = {
+ id: "me",
+ group: "me",
+ size: 50,
+ _originalSize: 50,
shape: "circularImage",
_originalShape: "circularImage",
- image: isConnected
+ image: this.reticulumLogoPath,
+ label: meLabel,
+ title: fullReq.me_title,
+ color: this.nodeColor("#3b82f6", isDarkMode ? "#1e40af" : "#eff6ff"),
+ font: { color: isDarkMode ? "#ffffff" : "#000000", size: 16, bold: true },
+ x: mp.x,
+ y: mp.y,
+ };
+ meNode = { ...meNode, ...this.getNodeLODProps(meNode, this.currentLOD) };
+ localNodes.push(meNode);
+ }
+ for (const entry of interfacesPayload) {
+ const pos = this.pickStablePosition(entry.name, posById, () => ({ x: 400, y: 0 }));
+ let n = {
+ id: entry.name,
+ group: "interface",
+ label: entry.label,
+ title: entry.title,
+ size: 35,
+ _originalSize: 35,
+ shape: "circularImage",
+ _originalShape: "circularImage",
+ image: entry.online
? "/assets/images/network-visualiser/interface_connected.png"
: "/assets/images/network-visualiser/interface_disconnected.png",
- color: this.nodeColor(
- isConnected ? "#06b6d4" : "#64748b",
- isDarkMode ? (isConnected ? "#164e63" : "#1e293b") : isConnected ? "#ecfeff" : "#f1f5f9"
- ),
- font: { color: fontColor, size: 10 },
- x: dp.x,
- y: dp.y,
+ color: this.nodeColor(entry.online ? "#10b981" : "#ef4444", isDarkMode ? "#064e3b" : "#ecfdf5"),
+ font: { color: isDarkMode ? "#ffffff" : "#000000", size: 12, bold: true },
+ x: pos.x,
+ y: pos.y,
};
- discNode = { ...discNode, ...this.getNodeLODProps(discNode, this.currentLOD) };
- discoveredNodes.push(discNode);
- processedNodeIds.add(discId);
-
- const edgeId = `me~${discId}`;
- discoveredEdges.push({
- id: edgeId,
+ n = { ...n, ...this.getNodeLODProps(n, this.currentLOD) };
+ localNodes.push(n);
+ localEdges.push({
+ id: `me~${entry.name}`,
from: "me",
- to: discId,
- color: {
- color: isDarkMode ? "#155e75" : "#06b6d4",
- opacity: 0.35,
- },
- width: 1,
+ to: entry.name,
+ color: entry.online
+ ? this.directEdgeColor(isDarkMode)
+ : { color: isDarkMode ? "#f87171" : "#ef4444", opacity: 1 },
+ width: 3,
+ hidden: false,
+ });
+ }
+ for (const entry of pathOnlyPayload) {
+ const pos = this.pickStablePosition(entry.name, posById, () => ({ x: 400, y: 0 }));
+ let n = {
+ id: entry.name,
+ group: "interface",
+ label: entry.label,
+ title: entry.title,
+ size: 35,
+ _originalSize: 35,
+ shape: "circularImage",
+ _originalShape: "circularImage",
+ image: "/assets/images/network-visualiser/interface_connected.png",
+ color: this.nodeColor("#10b981", isDarkMode ? "#064e3b" : "#ecfdf5"),
+ font: { color: isDarkMode ? "#ffffff" : "#000000", size: 12, bold: true },
+ x: pos.x,
+ y: pos.y,
+ };
+ n = { ...n, ...this.getNodeLODProps(n, this.currentLOD) };
+ localNodes.push(n);
+ localEdges.push({
+ id: `me~${entry.name}`,
+ from: "me",
+ to: entry.name,
+ color: this.directEdgeColor(isDarkMode),
+ width: 3,
hidden: false,
});
- processedEdgeIds.add(edgeId);
}
+ graphNodes = [...localNodes, ...graphNodes];
+ graphEdges = [...localEdges, ...graphEdges];
+ graph.layout_nodes = graphNodes.map((n) => ({
+ id: n.id,
+ x: n.x,
+ y: n.y,
+ mass: n.group === "me" ? 4 : n.group === "interface" ? 2.5 : 1,
+ fixed: n.id === "me",
+ }));
+ graph.layout_edges = graphEdges.map((e) => ({
+ from: e.from,
+ to: e.to,
+ length: e.width >= 2.5 ? 150 : 180,
+ }));
}
- if (discoveredNodes.length > 0) this.nodes.update(discoveredNodes);
- if (discoveredEdges.length > 0) this.edges.update(discoveredEdges);
+ this.loadingStatus = "Settling layout...";
+ const layoutNodes = Array.isArray(graph.layout_nodes) ? graph.layout_nodes : [];
+ const layoutEdges = Array.isArray(graph.layout_edges) ? graph.layout_edges : [];
+ // With Live Layout off, keep existing coordinates. Only settle when
+ // physics is on, or when nodes still lack cached positions.
+ const missingPositions = layoutNodes.some((n) => {
+ const p = posById[n.id];
+ return !(p && Number.isFinite(p.x) && Number.isFinite(p.y));
+ });
+ const shouldSettle =
+ layoutNodes.length > 0 && (this.enablePhysics || (isVisualiserWasmReady() && missingPositions));
+ if (shouldSettle) {
+ const settled = settleLayout({
+ nodes: layoutNodes,
+ edges: layoutEdges,
+ iterations: 0,
+ });
+ const positions = settled?.positions || {};
+ for (const node of graphNodes) {
+ const p = positions[node.id];
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ node.x = p.x;
+ node.y = p.y;
+ posById[node.id] = { x: p.x, y: p.y };
+ }
+ }
+ this.cachedPositions = { ...this.cachedPositions, ...posById };
+ }
if (!isCurrentRun()) return;
- // Process path table in batches to prevent UI block
- this.totalNodesToLoad = this.pathTable.length;
- this.loadedNodesCount = 0;
-
- const aspectsToShow = ["lxmf.delivery", "nomadnetwork.node"];
+ for (const node of graphNodes) {
+ const announce = this.announces[node.id];
+ if (announce) node._announce = announce;
+ }
+ if (Array.isArray(graph.icon_queue) && graph.icon_queue.length > 0) {
+ this.iconQueue.push(...graph.icon_queue);
+ }
+ for (const node of graphNodes) {
+ if (node?.id) processedNodeIds.add(node.id);
+ }
+ for (const edge of graphEdges) {
+ if (edge?.id) processedEdgeIds.add(edge.id);
+ }
- /*
- * Chunk size is adaptive to hardwareConcurrency. Smaller chunks
- * on weak hardware mean more frequent yields, which keeps the
- * loading overlay animating and keeps input responsive at the
- * cost of slightly higher total work due to extra event-loop
- * round-trips. The trade-off massively favours smoothness on
- * ARM SBCs.
- */
const chunkSize = this.vizChunkSize;
- this.totalBatches = Math.ceil(this.pathTable.length / chunkSize);
+ this.totalBatches = Math.max(1, Math.ceil(Math.max(graphNodes.length, graphEdges.length) / chunkSize));
this.currentBatch = 0;
-
- for (let i = 0; i < this.pathTable.length; i += chunkSize) {
+ const applyLimit = Math.max(graphNodes.length, graphEdges.length);
+ for (let i = 0; i < applyLimit; i += chunkSize) {
if (!isCurrentRun()) return;
this.currentBatch++;
- const chunk = this.pathTable.slice(i, i + chunkSize);
- const batchNodes = [];
- const batchEdges = [];
-
- for (const entry of chunk) {
- this.loadedNodesCount++;
- if (entry.hops == null) continue;
- if (this.hopFilterMax != null && entry.hops > this.hopFilterMax) continue;
-
- const announce = this.announces[entry.hash];
- if (!announce || !aspectsToShow.includes(announce.aspect)) continue;
-
- const displayName = announce.custom_display_name ?? announce.display_name;
- if (
- !matchesSearch(displayName) &&
- !matchesSearch(announce.destination_hash) &&
- !matchesSearch(announce.identity_hash)
- ) {
- continue;
- }
-
- const conversation = this.conversations[announce.destination_hash];
- const ip = posById[entry.interface];
- let initX = 0;
- let initY = 0;
-
- if (ip && Number.isFinite(ip.x) && Number.isFinite(ip.y)) {
- const angle = Math.random() * 2 * Math.PI;
- const dist = 150 + Math.random() * 150;
- initX = ip.x + Math.cos(angle) * dist;
- initY = ip.y + Math.sin(angle) * dist;
- } else {
- const angle = Math.random() * 2 * Math.PI;
- const dist = 600 + Math.random() * 200;
- initX = Math.cos(angle) * dist;
- initY = Math.sin(angle) * dist;
- }
-
- const targetXY = this.pickStablePosition(entry.hash, posById, () => ({ x: initX, y: initY }));
- const edgeId = `${entry.interface}~${entry.hash}`;
-
- let node = {
- id: entry.hash,
- group: "announce",
- size: 25,
- _originalSize: 25,
- _announce: announce,
- _parentInterface: entry.interface,
- font: { color: fontColor, size: 11 },
- x: targetXY.x,
- y: targetXY.y,
- };
-
- node.label = displayName;
- node.title = `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))}`;
-
- if (announce.aspect === "lxmf.delivery") {
- if (conversation?.lxmf_user_icon) {
- node.shape = "circularImage";
- node._originalShape = "circularImage";
- const cacheKey = `${conversation.lxmf_user_icon.icon_name}-${conversation.lxmf_user_icon.foreground_colour}-${conversation.lxmf_user_icon.background_colour}-64`;
- if (this.iconCache[cacheKey]) {
- node.image = this.iconCache[cacheKey];
- } else {
- /*
- * Defer custom-icon generation. Painting the
- * canvas + decoding the SVG inline used to
- * serialise every chunk and was the dominant
- * cause of the visualiser freezing on slow ARM
- * CPUs. Use a sensible placeholder (the same
- * default user image we use for icon-less lxmf
- * nodes) and queue the real icon for async
- * generation once all chunks are processed.
- */
- node.image = this.isDirectPathHop(entry.hops)
- ? "/assets/images/network-visualiser/user_1hop.png"
- : "/assets/images/network-visualiser/user.png";
- if (this.currentLOD !== "low") {
- this.iconQueue.push({
- nodeId: node.id,
- cacheKey,
- iconName: conversation.lxmf_user_icon.icon_name,
- fg: conversation.lxmf_user_icon.foreground_colour,
- bg: conversation.lxmf_user_icon.background_colour,
- size: 64,
- generation: this.iconQueueGeneration,
- });
- }
- }
- node.size = 30;
- node._originalSize = 30;
- } else {
- node.shape = "circularImage";
- node._originalShape = "circularImage";
- node.image = this.isDirectPathHop(entry.hops)
- ? "/assets/images/network-visualiser/user_1hop.png"
- : "/assets/images/network-visualiser/user.png";
- }
- node.color = this.nodeColor(
- this.isDirectPathHop(entry.hops) ? "#10b981" : "#3b82f6",
- this.isDirectPathHop(entry.hops)
- ? isDarkMode
- ? "#064e3b"
- : "#ecfdf5"
- : isDarkMode
- ? "#1e40af"
- : "#eff6ff"
- );
- } else if (announce.aspect === "nomadnetwork.node") {
- node.shape = "circularImage";
- node._originalShape = "circularImage";
- node.image = this.isDirectPathHop(entry.hops)
- ? "/assets/images/network-visualiser/server_1hop.png"
- : "/assets/images/network-visualiser/server.png";
- node.color = this.nodeColor(
- this.isDirectPathHop(entry.hops) ? "#10b981" : "#8b5cf6",
- this.isDirectPathHop(entry.hops)
- ? isDarkMode
- ? "#064e3b"
- : "#ecfdf5"
- : isDarkMode
- ? "#4c1d95"
- : "#f5f3ff"
- );
- }
-
- node = { ...node, ...this.getNodeLODProps(node, this.currentLOD) };
- batchNodes.push(node);
- processedNodeIds.add(node.id);
-
- const directHop = this.isDirectPathHop(entry.hops);
- batchEdges.push({
- id: edgeId,
- from: entry.interface,
- to: entry.hash,
- // Direct = brighter/thicker, and multi-hop = cooler/thinner.
- // No dashes/arrows, as both force expensive per-frame path work.
- color: directHop ? this.directEdgeColor(isDarkMode) : this.multiHopEdgeColor(isDarkMode),
- width: directHop ? 2.5 : 1,
- hidden: false,
- });
- processedEdgeIds.add(edgeId);
- }
-
+ const batchNodes = graphNodes.slice(i, i + chunkSize);
+ const batchEdges = graphEdges.slice(i, i + chunkSize);
if (batchNodes.length > 0) this.nodes.update(batchNodes);
if (batchEdges.length > 0) this.edges.update(batchEdges);
-
+ this.loadedNodesCount = Math.min(this.pathTable.length, i + chunkSize);
this.loadingStatus = `Processing Batch ${this.currentBatch} / ${this.totalBatches}...`;
-
- /*
- * Yield between chunks on large graphs so the overlay can paint.
- * Small graphs finish in one/few chunks — skip yields so we beat
- * upstream wall-clock on typical meshes.
- */
if (this.pathTable.length > VIZ_SYNC_PATH_THRESHOLD) {
await yieldToMain();
}
-
- if (!isCurrentRun()) return;
}
-
if (!isCurrentRun()) return;
- // Cleanup: remove nodes/edges that are no longer in the network
const nodesToRemove = this.nodes.getIds().filter((id) => !processedNodeIds.has(id));
if (nodesToRemove.length > 0) this.nodes.remove(nodesToRemove);
-
const edgesToRemove = this.edges.getIds().filter((id) => !processedEdgeIds.has(id));
if (edgesToRemove.length > 0) this.edges.remove(edgesToRemove);
@@ -1401,7 +1508,6 @@ export default {
this.loadedNodesCount = 0;
this.currentBatch = 0;
this.totalBatches = 0;
-
this.scheduleIconQueue();
},
scheduleIconQueue() {

diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
index b897afca..881aed63 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -23,61 +23,23 @@
<div class="flex items-center gap-2">
<button
type="button"
- class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
+ class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95 disabled:opacity-60"
:disabled="isUpdating || isLoading"
+ :aria-label="$t('visualiser.refresh')"
@click.stop="$emit('manual-update')"
>
- <svg
- v-if="!isUpdating && !isLoading"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
+ <MaterialDesignIcon
+ :icon-name="isUpdating || isLoading ? 'loading' : 'refresh'"
class="w-4 h-4 sm:w-5 sm:h-5"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
- />
- </svg>
- <svg
- v-else
- class="animate-spin h-4 w-4 sm:w-5 sm:h-5"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- >
- <circle
- class="opacity-25"
- cx="12"
- cy="12"
- r="10"
- stroke="currentColor"
- stroke-width="4"
- ></circle>
- <path
- class="opacity-75"
- fill="currentColor"
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
- ></path>
- </svg>
+ :class="{ 'animate-spin': isUpdating || isLoading }"
+ />
</button>
<div class="w-5 sm:w-6 flex justify-center">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 20 20"
- fill="currentColor"
+ <MaterialDesignIcon
+ icon-name="chevron-down"
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
:class="{ 'rotate-180': isShowingControls }"
- >
- <path
- fill-rule="evenodd"
- d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
- clip-rule="evenodd"
- />
- </svg>
+ />
</div>
</div>
</div>
@@ -88,11 +50,39 @@
>
<div class="h-px bg-linear-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"></div>
+ <div class="grid grid-cols-2 gap-2">
+ <div
+ class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
+ :title="engineModeTitle"
+ >
+ <div
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
+ >
+ {{ $t("visualiser.engine") }}
+ </div>
+ <div class="text-xs font-bold truncate" :class="engineModeClass">
+ {{ engineModeLabel }}
+ </div>
+ </div>
+ <div
+ class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
+ >
+ <div
+ class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
+ >
+ {{ $t("visualiser.fps") }}
+ </div>
+ <div class="text-xs font-bold text-gray-800 dark:text-zinc-100 tabular-nums">
+ {{ fpsDisplay }}
+ </div>
+ </div>
+ </div>
+
<div class="flex items-center justify-between">
<label
for="auto-reload"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
- >Auto Update</label
+ >{{ $t("visualiser.auto_update") }}</label
>
<Toggle
id="auto-reload"
@@ -105,7 +95,7 @@
<label
for="enable-physics"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
- >Live Layout</label
+ >{{ $t("visualiser.live_layout") }}</label
>
<Toggle
id="enable-physics"
@@ -156,7 +146,7 @@
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
- Nodes
+ {{ $t("visualiser.nodes") }}
</div>
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodeCount }}</div>
</div>
@@ -166,7 +156,7 @@
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
- Links
+ {{ $t("visualiser.links") }}
</div>
<div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">{{ edgeCount }}</div>
</div>
@@ -176,19 +166,19 @@
class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2">
- Interfaces
+ {{ $t("visualiser.interfaces") }}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
- >{{ onlineInterfaceCount }} Online</span
+ >{{ onlineInterfaceCount }} {{ $t("visualiser.online") }}</span
>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
- >{{ offlineInterfaceCount }} Offline</span
+ >{{ offlineInterfaceCount }} {{ $t("visualiser.offline") }}</span
>
</div>
</div>
@@ -201,18 +191,12 @@
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
>
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
- <path
- fill-rule="evenodd"
- d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
- clip-rule="evenodd"
- />
- </svg>
+ <MaterialDesignIcon icon-name="magnify" class="w-4 h-4" />
</div>
<input
:value="searchQuery"
type="text"
- :placeholder="`Search nodes (${nodeCount})...`"
+ :placeholder="$t('visualiser.search_nodes_placeholder', { count: nodeCount })"
class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/90 dark:bg-zinc-900/90 border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 md:max-lg:focus:w-72 lg:focus:w-80 transition-all dark:text-zinc-100 shadow-xs"
@input="$emit('update:searchQuery', $event.target.value)"
/>
@@ -220,13 +204,10 @@
v-if="searchQuery"
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
+ :aria-label="$t('visualiser.clear_search')"
@click="$emit('update:searchQuery', '')"
>
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
- <path
- d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
- />
- </svg>
+ <MaterialDesignIcon icon-name="close" class="w-4 h-4" />
</button>
</div>
</div>
@@ -235,11 +216,12 @@
<script>
import Toggle from "../../forms/Toggle.vue";
+import MaterialDesignIcon from "../../MaterialDesignIcon.vue";
import { HOP_SLIDER_POS_ALL, hopSliderPosToMaxHops, hopMaxHopsToSliderPos } from "./hopMaxFilterSliderMap.js";
export default {
name: "NetworkVisualiserToolbar",
- components: { Toggle },
+ components: { Toggle, MaterialDesignIcon },
props: {
isShowingControls: { type: Boolean, default: true },
isUpdating: { type: Boolean, default: false },
@@ -257,6 +239,14 @@ export default {
onlineInterfaceCount: { type: Number, default: 0 },
offlineInterfaceCount: { type: Number, default: 0 },
searchQuery: { type: String, default: "" },
+ engineMode: {
+ type: String,
+ default: "checking",
+ validator(v) {
+ return ["checking", "wasm", "fallback"].includes(v);
+ },
+ },
+ fps: { type: Number, default: 0 },
},
emits: [
"update:isShowingControls",
@@ -287,6 +277,26 @@ export default {
if (this.hopMaxFilter === null) return this.$t("visualiser.all");
return String(this.hopMaxFilter);
},
+ engineModeLabel() {
+ if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm");
+ if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback");
+ return this.$t("visualiser.engine_checking");
+ },
+ engineModeTitle() {
+ if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm_hint");
+ if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback_hint");
+ return this.$t("visualiser.engine_checking_hint");
+ },
+ engineModeClass() {
+ if (this.engineMode === "wasm") return "text-emerald-600 dark:text-emerald-400";
+ if (this.engineMode === "fallback") return "text-amber-600 dark:text-amber-400";
+ return "text-gray-500 dark:text-zinc-400";
+ },
+ fpsDisplay() {
+ const n = Number(this.fps);
+ if (!Number.isFinite(n) || n <= 0) return "--";
+ return String(Math.round(n));
+ },
},
methods: {
onHopSliderInput(e) {

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index c26dc12e..280e79c7 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -1206,6 +1206,9 @@ export default {
};
const flush = () => {
this._layoutPersistTimer = null;
+ if (typeof window === "undefined" || !window.api) {
+ return undefined;
+ }
return saveNomadFavouritesLayout(window.api, layout);
};
if (options.immediate) {

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index f8ab5eda..fae8ab63 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -1600,6 +1600,292 @@
</div>
</section>
+ <!-- Battery saver -->
+ <section v-show="showSection('battery')" class="settings-section break-inside-avoid">
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">{{ $t("settings.battery.eyebrow") }}</div>
+ <h2>{{ $t("settings.battery.title") }}</h2>
+ <p>{{ $t("settings.battery.description") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-saver-enabled"
+ v-model="batterySaver.enabled"
+ @update:model-value="onBatterySaverEnabledChange"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("settings.battery.enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.enabled_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100 pt-2">
+ {{ $t("settings.battery.options_heading") }}
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-viz-discovery"
+ v-model="batterySaver.disableVisualiserDiscovery"
+ @update:model-value="
+ (v) => patchBatterySaver({ disableVisualiserDiscovery: v })
+ "
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.disable_visualiser_discovery")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.disable_visualiser_discovery_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-hide-offline"
+ v-model="batterySaver.hideOfflineInterfaces"
+ @update:model-value="(v) => patchBatterySaver({ hideOfflineInterfaces: v })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.hide_offline_interfaces")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.hide_offline_interfaces_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.max_visualiser_interfaces") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.max_visualiser_interfaces_desc") }}
+ </p>
+ <input
+ v-model.number="batterySaver.maxVisualiserInterfaces"
+ type="number"
+ min="0"
+ max="128"
+ class="input-field"
+ @change="
+ patchBatterySaver({
+ maxVisualiserInterfaces: batterySaver.maxVisualiserInterfaces,
+ })
+ "
+ />
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.visualiser_reload_seconds") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.visualiser_reload_seconds_desc") }}
+ </p>
+ <input
+ v-model.number="batterySaver.visualiserReloadSeconds"
+ type="number"
+ min="0"
+ max="600"
+ class="input-field"
+ @change="
+ patchBatterySaver({
+ visualiserReloadSeconds: batterySaver.visualiserReloadSeconds,
+ })
+ "
+ />
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-live-layout"
+ v-model="batterySaver.disableVisualiserLiveLayout"
+ @update:model-value="
+ (v) => patchBatterySaver({ disableVisualiserLiveLayout: v })
+ "
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.disable_visualiser_live_layout")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.disable_visualiser_live_layout_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-bg-poll"
+ v-model="batterySaver.reduceBackgroundPolling"
+ @update:model-value="(v) => patchBatterySaver({ reduceBackgroundPolling: v })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.reduce_background_polling")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.reduce_background_polling_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.background_poll_multiplier") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.background_poll_multiplier_desc") }}
+ </p>
+ <input
+ v-model.number="batterySaver.backgroundPollMultiplier"
+ type="number"
+ min="2"
+ max="10"
+ class="input-field"
+ @change="
+ patchBatterySaver({
+ backgroundPollMultiplier: batterySaver.backgroundPollMultiplier,
+ })
+ "
+ />
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-ifaces-discovery"
+ v-model="batterySaver.reduceInterfacesDiscovery"
+ @update:model-value="(v) => patchBatterySaver({ reduceInterfacesDiscovery: v })"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.reduce_interfaces_discovery")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.reduce_interfaces_discovery_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.interfaces_stats_poll_seconds") }}
+ </div>
+ <input
+ v-model.number="batterySaver.interfacesStatsPollSeconds"
+ type="number"
+ min="1"
+ max="120"
+ class="input-field"
+ @change="
+ patchBatterySaver({
+ interfacesStatsPollSeconds: batterySaver.interfacesStatsPollSeconds,
+ })
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("settings.battery.interfaces_discovery_poll_seconds") }}
+ </div>
+ <input
+ v-model.number="batterySaver.interfacesDiscoveryPollSeconds"
+ type="number"
+ min="5"
+ max="300"
+ class="input-field"
+ @change="
+ patchBatterySaver({
+ interfacesDiscoveryPollSeconds:
+ batterySaver.interfacesDiscoveryPollSeconds,
+ })
+ "
+ />
+ </div>
+ </div>
+
+ <label class="setting-toggle">
+ <Toggle
+ id="settings-battery-bitrate-limits"
+ v-model="batterySaver.applyInterfaceBitrateLimits"
+ @update:model-value="
+ (v) => patchBatterySaver({ applyInterfaceBitrateLimits: v === true })
+ "
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("settings.battery.apply_interface_bitrate_limits")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("settings.battery.apply_interface_bitrate_limits_desc")
+ }}</span>
+ </span>
+ </label>
+
+ <div v-if="batterySaver.applyInterfaceBitrateLimits" class="space-y-3">
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("settings.battery.interface_bitrate_limits_help") }}
+ </p>
+ <div
+ v-if="batteryInterfaceRows.length === 0"
+ class="text-xs text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("settings.battery.interface_bitrate_limits_empty") }}
+ </div>
+ <div
+ v-for="row in batteryInterfaceRows"
+ :key="row.name"
+ class="grid grid-cols-1 sm:grid-cols-[1fr_10rem] gap-2 items-center"
+ >
+ <div
+ class="text-sm text-gray-900 dark:text-gray-100 truncate"
+ :title="row.name"
+ >
+ {{ row.name }}
+ <span class="text-xs text-gray-500 dark:text-zinc-400">
+ ({{ row.type || "?" }})
+ </span>
+ </div>
+ <input
+ v-model.number="batterySaver.interfaceBitrateLimits[row.name]"
+ type="number"
+ min="0"
+ class="input-field"
+ :placeholder="$t('settings.battery.interface_bitrate_placeholder')"
+ @change="onBatteryBitrateLimitChange(row.name)"
+ />
+ </div>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="secondary-button text-sm"
+ :disabled="batteryBitrateBusy"
+ @click="applyBatteryBitrateLimitsNow"
+ >
+ {{ $t("settings.battery.apply_bitrates_reload") }}
+ </button>
+ <button
+ type="button"
+ class="secondary-button text-sm"
+ :disabled="batteryBitrateBusy"
+ @click="restoreBatteryBitrateLimitsNow"
+ >
+ {{ $t("settings.battery.restore_bitrates_reload") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- Network Visualiser -->
<section v-show="showSection('visualiser')" class="settings-section break-inside-avoid">
<header class="settings-section__header">
@@ -3296,6 +3582,11 @@ import {
persistVisualiserShowDisabled,
persistVisualiserShowDiscovered,
} from "../../js/settings/settingsVisualiserPrefs";
+import { loadBatterySaverPrefs, saveBatterySaverPrefs } from "../../js/settings/batterySaverPrefs.js";
+import {
+ applyBatterySaverBitrateLimits,
+ restoreBatterySaverBitrateLimits,
+} from "../../js/settings/batterySaverBitrateApply.js";
import {
incomingDeliveryBytesFromCustom,
incomingDeliveryBytesFromPresetKey,
@@ -3461,6 +3752,9 @@ export default {
gifImportReplaceDuplicates: false,
visualiserShowDisabledInterfaces: false,
visualiserShowDiscoveredInterfaces: false,
+ batterySaver: loadBatterySaverPrefs(),
+ batteryInterfaceRows: [],
+ batteryBitrateBusy: false,
selfTestRunning: false,
selfTestResults: null,
selfTestExpandedReasons: {},
@@ -3703,10 +3997,108 @@ export default {
this.loadStickerCount();
this.loadGifCount();
this.loadVisualiserDisplayPrefsFromStorage();
+ this.loadBatterySaverPrefsFromStorage();
+ this.loadBatteryInterfaceRows();
this.loadDesktopCloseSettings();
this.loadReticulumInstanceSettings();
},
methods: {
+ loadBatterySaverPrefsFromStorage() {
+ this.batterySaver = loadBatterySaverPrefs();
+ if (!this.batterySaver.interfaceBitrateLimits) {
+ this.batterySaver.interfaceBitrateLimits = {};
+ }
+ },
+ async loadBatteryInterfaceRows() {
+ try {
+ const response = await window.api.get("/api/v1/reticulum/interfaces");
+ const interfaces = response?.data?.interfaces || {};
+ this.batteryInterfaceRows = Object.entries(interfaces)
+ .map(([name, iface]) => ({
+ name,
+ type: iface?.type || "",
+ bitrate: iface?.bitrate ?? null,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ for (const row of this.batteryInterfaceRows) {
+ if (this.batterySaver.interfaceBitrateLimits[row.name] == null && row.bitrate != null) {
+ const n = Number(row.bitrate);
+ if (Number.isFinite(n) && n >= 0) {
+ // leave unset so empty means "no forced limit"
+ }
+ }
+ }
+ } catch {
+ this.batteryInterfaceRows = [];
+ }
+ },
+ patchBatterySaver(patch) {
+ this.batterySaver = saveBatterySaverPrefs(patch);
+ if (!this.batterySaver.interfaceBitrateLimits) {
+ this.batterySaver.interfaceBitrateLimits = {};
+ }
+ },
+ onBatterySaverEnabledChange(val) {
+ const enabled = val === true;
+ this.patchBatterySaver({ enabled });
+ if (enabled && this.batterySaver.applyInterfaceBitrateLimits) {
+ this.applyBatteryBitrateLimitsNow();
+ } else if (!enabled && Object.keys(this.batterySaver.interfaceBitratePrevious || {}).length > 0) {
+ this.restoreBatteryBitrateLimitsNow();
+ }
+ },
+ onBatteryBitrateLimitChange(name) {
+ const limits = { ...(this.batterySaver.interfaceBitrateLimits || {}) };
+ const raw = limits[name];
+ if (raw === "" || raw == null || Number.isNaN(Number(raw))) {
+ delete limits[name];
+ } else {
+ limits[name] = Math.max(0, Math.round(Number(raw)));
+ }
+ this.patchBatterySaver({ interfaceBitrateLimits: limits });
+ },
+ async applyBatteryBitrateLimitsNow() {
+ if (this.batteryBitrateBusy) return;
+ this.batteryBitrateBusy = true;
+ try {
+ this.patchBatterySaver({
+ applyInterfaceBitrateLimits: true,
+ interfaceBitrateLimits: { ...(this.batterySaver.interfaceBitrateLimits || {}) },
+ });
+ const result = await applyBatterySaverBitrateLimits({ reload: true });
+ this.loadBatterySaverPrefsFromStorage();
+ if (result.updated.length === 0) {
+ ToastUtils.error(this.$t("settings.battery.bitrates_none_applied"));
+ } else {
+ ToastUtils.success(this.$t("settings.battery.bitrates_applied", { count: result.updated.length }));
+ }
+ await this.loadBatteryInterfaceRows();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("settings.battery.bitrates_apply_failed"));
+ } finally {
+ this.batteryBitrateBusy = false;
+ }
+ },
+ async restoreBatteryBitrateLimitsNow() {
+ if (this.batteryBitrateBusy) return;
+ this.batteryBitrateBusy = true;
+ try {
+ const result = await restoreBatterySaverBitrateLimits({ reload: true });
+ this.loadBatterySaverPrefsFromStorage();
+ if (result.updated.length === 0) {
+ ToastUtils.error(this.$t("settings.battery.bitrates_none_restored"));
+ } else {
+ ToastUtils.success(this.$t("settings.battery.bitrates_restored", { count: result.updated.length }));
+ }
+ await this.loadBatteryInterfaceRows();
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error(this.$t("settings.battery.bitrates_restore_failed"));
+ } finally {
+ this.batteryBitrateBusy = false;
+ }
+ },
async loadReticulumInstanceSettings() {
try {
const instance = await fetchReticulumInstanceSettings(window.api);

diff --git a/meshchatx/src/frontend/js/VisualiserWasmLoader.js b/meshchatx/src/frontend/js/VisualiserWasmLoader.js
new file mode 100644
index 00000000..36bc78ed
--- /dev/null
+++ b/meshchatx/src/frontend/js/VisualiserWasmLoader.js
@@ -0,0 +1,206 @@
+/**
+ * Lazy-load visualiser-wasm (Go) for network visualiser hot paths.
+ * Falls back silently when WebAssembly is unavailable or load fails.
+ * Artifacts live under /vendor/visualiser-wasm/ (built via task build:visualiser-wasm).
+ */
+
+let resolvedPromise = null;
+let integrityHashes = null;
+
+/** Computes SHA-384 hash of ArrayBuffer for SRI verification. */
+async function computeSriHash(buf) {
+ const hash = await crypto.subtle.digest("SHA-384", buf);
+ const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
+ return `sha384-${base64}`;
+}
+
+/** True when WASM artifacts were present at Vite build time. */
+export function isVisualiserWasmBundled() {
+ if (
+ typeof globalThis !== "undefined" &&
+ typeof globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ === "boolean"
+ ) {
+ return globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__;
+ }
+ return import.meta.env.VITE_VISUALISER_WASM_BUNDLED === "true";
+}
+
+function baseUrl() {
+ const root = import.meta.env.BASE_URL || "/";
+ return `${root.replace(/\/?$/, "/")}vendor/visualiser-wasm`;
+}
+
+async function getIntegrityHashes() {
+ if (integrityHashes !== null) {
+ return integrityHashes;
+ }
+ const embeddedWasm = typeof __VISUALISER_WASM_SRI_WASM__ !== "undefined" ? __VISUALISER_WASM_SRI_WASM__ : "";
+ const embeddedExec = typeof __VISUALISER_WASM_SRI_EXEC__ !== "undefined" ? __VISUALISER_WASM_SRI_EXEC__ : "";
+ if (embeddedWasm && embeddedExec) {
+ integrityHashes = { wasm: embeddedWasm, wasmExec: embeddedExec };
+ return integrityHashes;
+ }
+ try {
+ const res = await fetch(`${baseUrl()}/integrity.json`);
+ if (!res.ok) return null;
+ integrityHashes = await res.json();
+ return integrityHashes;
+ } catch {
+ return null;
+ }
+}
+
+async function verifySri(buf, expectedHash, name) {
+ if (!expectedHash) {
+ throw new Error(`Visualiser WASM: SRI hash missing for ${name}. Refusing to load untrusted code.`);
+ }
+ const actualHash = await computeSriHash(buf);
+ if (actualHash !== expectedHash) {
+ throw new Error(
+ `Visualiser WASM: SRI hash mismatch for ${name}. Possible tampering detected. Refusing to execute.`
+ );
+ }
+}
+
+async function injectScript(src, expectedHash) {
+ const id = "meshchatx-visualiser-wasm-exec";
+ if (document.getElementById(id)) {
+ return;
+ }
+ const res = await fetch(src);
+ if (!res.ok) {
+ throw new Error(`Visualiser WASM: failed to fetch script ${src} (${res.status})`);
+ }
+ const buf = await res.arrayBuffer();
+ await verifySri(buf, expectedHash, "wasm_exec.js");
+ const blob = new Blob([buf], { type: "application/javascript" });
+ const blobUrl = URL.createObjectURL(blob);
+ return new Promise((resolve, reject) => {
+ const s = document.createElement("script");
+ s.id = id;
+ s.async = true;
+ s.src = blobUrl;
+ s.onload = () => {
+ URL.revokeObjectURL(blobUrl);
+ resolve();
+ };
+ s.onerror = () => {
+ URL.revokeObjectURL(blobUrl);
+ reject(new Error(`Visualiser WASM: failed to load script ${src}`));
+ };
+ document.head.appendChild(s);
+ });
+}
+
+async function instantiateWasmBuffer(buf, go) {
+ let result;
+ try {
+ result = await WebAssembly.instantiateStreaming(
+ new Response(buf, { headers: { "content-type": "application/wasm" } }),
+ go.importObject
+ );
+ } catch {
+ result = await WebAssembly.instantiate(buf, go.importObject);
+ }
+ go.run(result.instance);
+}
+
+function isReady() {
+ return (
+ typeof globalThis.meshchatxVisualiserBuildPathGraph === "function" &&
+ typeof globalThis.meshchatxVisualiserBuildFullGraph === "function" &&
+ typeof globalThis.meshchatxVisualiserLayout === "function" &&
+ typeof globalThis.meshchatxVisualiserPathHashes === "function" &&
+ typeof globalThis.meshchatxVisualiserDedupeIcons === "function"
+ );
+}
+
+async function instantiateOnce() {
+ if (typeof WebAssembly === "undefined") {
+ throw new Error("Visualiser WASM: WebAssembly is not available");
+ }
+ const root = baseUrl();
+ const integrity = await getIntegrityHashes();
+ if (!integrity?.wasmExec) {
+ throw new Error("Visualiser WASM: wasm_exec SRI missing (build without WASM vendor files?)");
+ }
+
+ if (typeof globalThis.Go === "undefined") {
+ await injectScript(`${root}/wasm_exec.js`, integrity.wasmExec);
+ }
+ if (typeof globalThis.Go === "undefined") {
+ throw new Error("Visualiser WASM: Go runtime missing after wasm_exec.js load");
+ }
+ const go = new globalThis.Go();
+
+ const wasmUrl = `${root}/visualiser.wasm`;
+ const res = await fetch(wasmUrl);
+ if (!res.ok) {
+ throw new Error(`Visualiser WASM: fetch failed (${res.status})`);
+ }
+ const buf = await res.arrayBuffer();
+ await verifySri(buf, integrity?.wasm, "visualiser.wasm");
+ await instantiateWasmBuffer(buf, go);
+
+ if (!isReady()) {
+ throw new Error("Visualiser WASM: exports were not registered");
+ }
+}
+
+/**
+ * Ensures visualiser WASM is initialized.
+ * Resolves true when exports are callable, false when unavailable or failed.
+ */
+export function preloadVisualiserWasm() {
+ if (!isVisualiserWasmBundled()) {
+ return Promise.resolve(false);
+ }
+ if (isReady()) {
+ return Promise.resolve(true);
+ }
+ if (resolvedPromise === null) {
+ resolvedPromise = (async () => {
+ try {
+ await instantiateOnce();
+ return isReady();
+ } catch (e) {
+ console.warn(e);
+ resolvedPromise = null;
+ return false;
+ }
+ })();
+ }
+ return resolvedPromise;
+}
+
+export function isVisualiserWasmReady() {
+ return isReady();
+}
+
+/**
+ * Call a WASM JSON export and parse the result string.
+ * Returns null on any failure so callers can fall back to JS.
+ */
+export function callVisualiserWasmJson(fnName, ...args) {
+ try {
+ const fn = globalThis[fnName];
+ if (typeof fn !== "function") {
+ return null;
+ }
+ const raw = fn(...args);
+ if (raw == null) {
+ return null;
+ }
+ if (typeof raw === "object" && raw.ok === false) {
+ console.warn("Visualiser WASM error:", raw.error);
+ return null;
+ }
+ if (typeof raw !== "string") {
+ return null;
+ }
+ return JSON.parse(raw);
+ } catch (e) {
+ console.warn("Visualiser WASM call failed:", e);
+ return null;
+ }
+}

diff --git a/meshchatx/src/frontend/js/networkVisualiserCache.js b/meshchatx/src/frontend/js/networkVisualiserCache.js
new file mode 100644
index 00000000..3a2b0e71
--- /dev/null
+++ b/meshchatx/src/frontend/js/networkVisualiserCache.js
@@ -0,0 +1,158 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+/**
+ * Identity-scoped IndexedDB cache for the network visualiser.
+ * Stores path rows, announces, and node positions so reopen can paint quickly
+ * and only fetch announces for newly seen destination hashes.
+ */
+
+const DB_NAME = "meshchatx_visualiser_cache";
+const DB_VERSION = 1;
+const STORE_NAME = "snapshots";
+const CACHE_VERSION = 1;
+const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
+
+let dbPromise = null;
+
+function getIdb() {
+ return (
+ globalThis.indexedDB || globalThis.mozIndexedDB || globalThis.webkitIndexedDB || globalThis.msIndexedDB || null
+ );
+}
+
+function openDb() {
+ if (dbPromise) {
+ return dbPromise;
+ }
+ const idb = getIdb();
+ if (!idb) {
+ return Promise.reject(new Error("IndexedDB unavailable"));
+ }
+ dbPromise = new Promise((resolve, reject) => {
+ const req = idb.open(DB_NAME, DB_VERSION);
+ req.onerror = () => reject(req.error || new Error("IndexedDB open failed"));
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME, { keyPath: "identityHash" });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ });
+ return dbPromise;
+}
+
+function clonePlain(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+/**
+ * @param {string} identityHash
+ * @returns {Promise<{ identityHash: string, savedAt: number, pathTable: object[], announces: object, positions: object }|null>}
+ */
+export async function loadVisualiserCache(identityHash) {
+ if (!identityHash || typeof identityHash !== "string") {
+ return null;
+ }
+ try {
+ const db = await openDb();
+ const row = await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readonly");
+ const req = tx.objectStore(STORE_NAME).get(identityHash);
+ req.onsuccess = () => resolve(req.result || null);
+ req.onerror = () => reject(req.error);
+ });
+ if (!row || row.version !== CACHE_VERSION) {
+ return null;
+ }
+ if (typeof row.savedAt !== "number" || Date.now() - row.savedAt > MAX_AGE_MS) {
+ return null;
+ }
+ if (!Array.isArray(row.pathTable)) {
+ return null;
+ }
+ return {
+ identityHash: row.identityHash,
+ savedAt: row.savedAt,
+ pathTable: row.pathTable,
+ announces: row.announces && typeof row.announces === "object" ? row.announces : {},
+ positions: row.positions && typeof row.positions === "object" ? row.positions : {},
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @param {{ identityHash: string, pathTable: object[], announces: object, positions: object, pathSoftCap?: number, announceSoftCap?: number }} snapshot
+ */
+export async function saveVisualiserCache(snapshot) {
+ const identityHash = snapshot?.identityHash;
+ if (!identityHash || typeof identityHash !== "string") {
+ return false;
+ }
+ try {
+ const pathSoftCap = snapshot.pathSoftCap ?? 20_000;
+ const announceSoftCap = snapshot.announceSoftCap ?? 10_000;
+ let pathTable = Array.isArray(snapshot.pathTable) ? snapshot.pathTable : [];
+ if (pathTable.length > pathSoftCap) {
+ pathTable = pathTable.slice(0, pathSoftCap);
+ }
+ const announcesIn = snapshot.announces && typeof snapshot.announces === "object" ? snapshot.announces : {};
+ const announceKeys = Object.keys(announcesIn);
+ let announces = announcesIn;
+ if (announceKeys.length > announceSoftCap) {
+ announces = {};
+ for (const key of announceKeys.slice(0, announceSoftCap)) {
+ announces[key] = announcesIn[key];
+ }
+ }
+ const positions = snapshot.positions && typeof snapshot.positions === "object" ? snapshot.positions : {};
+
+ const row = {
+ identityHash,
+ version: CACHE_VERSION,
+ savedAt: Date.now(),
+ pathTable: clonePlain(pathTable),
+ announces: clonePlain(announces),
+ positions: clonePlain(positions),
+ };
+
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.objectStore(STORE_NAME).put(row);
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * @param {string} identityHash
+ */
+export async function clearVisualiserCache(identityHash) {
+ if (!identityHash) {
+ return false;
+ }
+ try {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE_NAME, "readwrite");
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.objectStore(STORE_NAME).delete(identityHash);
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Test helper to reset the open handle between tests. */
+export function resetVisualiserCacheDbHandle() {
+ dbPromise = null;
+}

diff --git a/meshchatx/src/frontend/js/networkVisualiserPerf.js b/meshchatx/src/frontend/js/networkVisualiserPerf.js
index 60177555..3c3ee79a 100644
--- a/meshchatx/src/frontend/js/networkVisualiserPerf.js
+++ b/meshchatx/src/frontend/js/networkVisualiserPerf.js
@@ -1,5 +1,12 @@
// SPDX-License-Identifier: 0BSD AND MIT
+/**
+ * Network visualiser hot-path helpers.
+ * Prefers Go WASM when available, otherwise uses the pure JS implementations below.
+ */
+
+import { callVisualiserWasmJson, isVisualiserWasmReady, preloadVisualiserWasm } from "./VisualiserWasmLoader.js";
+
export const VIZ_ANNOUNCE_ASPECTS = ["lxmf.delivery", "nomadnetwork.node"];
export const ANNOUNCE_HASH_CHUNK_SIZE = 500;
@@ -15,7 +22,7 @@ export const VIZ_ANNOUNCE_SOFT_CAP = 10_000;
* @param {number|null|undefined} hopMax
* @returns {string[]}
*/
-export function pathHashesWithinHopFilter(pathTable, hopMax) {
+export function pathHashesWithinHopFilterJs(pathTable, hopMax) {
if (!Array.isArray(pathTable) || pathTable.length === 0) {
return [];
}
@@ -39,12 +46,31 @@ export function pathHashesWithinHopFilter(pathTable, hopMax) {
return Array.from(out);
}
+/**
+ * @param {unknown[]} pathTable
+ * @param {number|null|undefined} hopMax
+ * @returns {string[]}
+ */
+export function pathHashesWithinHopFilter(pathTable, hopMax) {
+ if (isVisualiserWasmReady()) {
+ const got = callVisualiserWasmJson(
+ "meshchatxVisualiserPathHashes",
+ JSON.stringify(pathTable),
+ hopMax == null ? null : hopMax
+ );
+ if (Array.isArray(got)) {
+ return got;
+ }
+ }
+ return pathHashesWithinHopFilterJs(pathTable, hopMax);
+}
+
/**
* Collapse deferred icon work so each unique cacheKey is painted once.
* @param {unknown[]} queue
* @returns {{ cacheKey: string, nodeIds: string[], iconName: string, fg: string, bg: string, size: number, generation: number }[]}
*/
-export function dedupeIconQueueEntries(queue) {
+export function dedupeIconQueueEntriesJs(queue) {
if (!Array.isArray(queue) || queue.length === 0) {
return [];
}
@@ -77,6 +103,20 @@ export function dedupeIconQueueEntries(queue) {
return Array.from(byKey.values());
}
+/**
+ * @param {unknown[]} queue
+ * @returns {{ cacheKey: string, nodeIds: string[], iconName: string, fg: string, bg: string, size: number, generation: number }[]}
+ */
+export function dedupeIconQueueEntries(queue) {
+ if (isVisualiserWasmReady()) {
+ const got = callVisualiserWasmJson("meshchatxVisualiserDedupeIcons", JSON.stringify(queue));
+ if (Array.isArray(got)) {
+ return got;
+ }
+ }
+ return dedupeIconQueueEntriesJs(queue);
+}
+
/**
* Parallel path/announce fetch concurrency scaled to hardware.
* @returns {number}
@@ -88,3 +128,376 @@ export function pickAdaptiveFetchConcurrency() {
if (cores <= 6) return 4;
return 6;
}
+
+/** FNV-1a style deterministic unit fraction for stable layout without Math.random. */
+function hash01(id, salt = "") {
+ let h = 2166136261;
+ const s = String(id) + "\0" + String(salt);
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return ((h >>> 0) % 10000) / 10000;
+}
+
+function hashAngle(id) {
+ return hash01(id) * Math.PI * 2;
+}
+
+function nodeColor(border, background) {
+ return {
+ border,
+ background,
+ highlight: { border, background },
+ hover: { border, background },
+ };
+}
+
+function edgeColor(direct, darkMode) {
+ if (direct) {
+ return { color: darkMode ? "#34d399" : "#10b981", opacity: 1 };
+ }
+ return { color: darkMode ? "#60a5fa" : "#3b82f6", opacity: 0.5 };
+}
+
+function applyLodToAnnounceNode(node, lod, fontColor) {
+ if (lod === "low") {
+ node.shape = "dot";
+ node.size = node.id === "me" ? 15 : 10;
+ node.font = { size: 0 };
+ return;
+ }
+ if (lod === "medium") {
+ node.shape = node._originalShape || "circularImage";
+ node.size = node._originalSize || 25;
+ node.font = { size: 0 };
+ return;
+ }
+ node.shape = node._originalShape || "circularImage";
+ node.size = node._originalSize || 25;
+ node.font = { size: node.id === "me" ? 16 : 11, color: fontColor };
+}
+
+/**
+ * Pure JS path-table graph builder (fallback when WASM is unavailable).
+ * @param {object} req
+ * @returns {{ nodes: object[], edges: object[], icon_queue: object[], processed_node_ids: string[], processed_edge_ids: string[] }}
+ */
+export function buildPathGraphJs(req) {
+ const pathTable = Array.isArray(req?.path_table) ? req.path_table : [];
+ const announces = req?.announces && typeof req.announces === "object" ? req.announces : {};
+ const conversations = req?.conversations && typeof req.conversations === "object" ? req.conversations : {};
+ const iconCache = req?.icon_cache && typeof req.icon_cache === "object" ? req.icon_cache : {};
+ const positions = { ...(req?.positions && typeof req.positions === "object" ? req.positions : {}) };
+ const hopMax = req?.hop_max;
+ const searchLower = String(req?.search || "").toLowerCase();
+ const darkMode = !!req?.dark_mode;
+ const lod = req?.lod || "high";
+ const aspects = Array.isArray(req?.aspects) && req.aspects.length > 0 ? req.aspects : VIZ_ANNOUNCE_ASPECTS;
+ const aspectSet = new Set(aspects);
+ const queueIcons = !!req?.queue_icons;
+ const iconGeneration = req?.icon_generation || 0;
+ const fontColor = darkMode ? "#ffffff" : "#000000";
+ const matchesSearch = (text) => !searchLower || (text && String(text).toLowerCase().includes(searchLower));
+
+ const nodes = [];
+ const edges = [];
+ const iconQueue = [];
+ const processedNodeIds = [];
+ const processedEdgeIds = [];
+
+ for (const entry of pathTable) {
+ if (!entry || entry.hops == null || !entry.hash) continue;
+ if (hopMax != null && entry.hops > hopMax) continue;
+ const announce = announces[entry.hash];
+ if (!announce || !aspectSet.has(announce.aspect)) continue;
+
+ const displayName = announce.custom_display_name ?? announce.display_name;
+ if (
+ !matchesSearch(displayName) &&
+ !matchesSearch(announce.destination_hash) &&
+ !matchesSearch(announce.identity_hash)
+ ) {
+ continue;
+ }
+
+ let x;
+ let y;
+ const prev = positions[entry.hash];
+ if (prev && Number.isFinite(prev.x) && Number.isFinite(prev.y)) {
+ x = prev.x;
+ y = prev.y;
+ } else {
+ const ip = positions[entry.interface];
+ const angle = hashAngle(entry.hash);
+ if (ip && Number.isFinite(ip.x) && Number.isFinite(ip.y)) {
+ const dist = 150 + hash01(entry.hash, "r") * 150;
+ x = ip.x + Math.cos(angle) * dist;
+ y = ip.y + Math.sin(angle) * dist;
+ } else {
+ const dist = 600 + hash01(entry.hash, "r") * 200;
+ x = Math.cos(angle) * dist;
+ y = Math.sin(angle) * dist;
+ }
+ positions[entry.hash] = { x, y };
+ }
+
+ const edgeId = `${entry.interface}~${entry.hash}`;
+ const direct = entry.hops === 1;
+ const conversation = conversations[announce.destination_hash];
+
+ let node = {
+ id: entry.hash,
+ group: "announce",
+ size: 25,
+ _originalSize: 25,
+ font: { color: fontColor, size: 11 },
+ x,
+ y,
+ label: displayName,
+ title: `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${announce.last_seen || ""}`,
+ _parentInterface: entry.interface,
+ };
+
+ if (announce.aspect === "lxmf.delivery") {
+ node.shape = "circularImage";
+ node._originalShape = "circularImage";
+ if (conversation?.lxmf_user_icon) {
+ const ic = conversation.lxmf_user_icon;
+ const cacheKey = `${ic.icon_name}-${ic.foreground_colour}-${ic.background_colour}-64`;
+ if (iconCache[cacheKey]) {
+ node.image = iconCache[cacheKey];
+ } else {
+ node.image = direct
+ ? "/assets/images/network-visualiser/user_1hop.png"
+ : "/assets/images/network-visualiser/user.png";
+ if (queueIcons) {
+ iconQueue.push({
+ nodeId: node.id,
+ cacheKey,
+ iconName: ic.icon_name,
+ fg: ic.foreground_colour,
+ bg: ic.background_colour,
+ size: 64,
+ generation: iconGeneration,
+ });
+ }
+ }
+ node.size = 30;
+ node._originalSize = 30;
+ } else {
+ node.image = direct
+ ? "/assets/images/network-visualiser/user_1hop.png"
+ : "/assets/images/network-visualiser/user.png";
+ }
+ node.color = nodeColor(
+ direct ? "#10b981" : "#3b82f6",
+ direct ? (darkMode ? "#064e3b" : "#ecfdf5") : darkMode ? "#1e40af" : "#eff6ff"
+ );
+ } else if (announce.aspect === "nomadnetwork.node") {
+ node.shape = "circularImage";
+ node._originalShape = "circularImage";
+ node.image = direct
+ ? "/assets/images/network-visualiser/server_1hop.png"
+ : "/assets/images/network-visualiser/server.png";
+ node.color = nodeColor(
+ direct ? "#10b981" : "#8b5cf6",
+ direct ? (darkMode ? "#064e3b" : "#ecfdf5") : darkMode ? "#4c1d95" : "#f5f3ff"
+ );
+ }
+
+ applyLodToAnnounceNode(node, lod, fontColor);
+ nodes.push(node);
+ processedNodeIds.push(node.id);
+ edges.push({
+ id: edgeId,
+ from: entry.interface,
+ to: entry.hash,
+ color: edgeColor(direct, darkMode),
+ width: direct ? 2.5 : 1,
+ hidden: false,
+ });
+ processedEdgeIds.push(edgeId);
+ }
+
+ return {
+ nodes,
+ edges,
+ icon_queue: iconQueue,
+ processed_node_ids: processedNodeIds,
+ processed_edge_ids: processedEdgeIds,
+ };
+}
+
+/**
+ * Build announce nodes/edges from the path table via WASM or JS fallback.
+ * @param {object} req
+ */
+export function buildPathGraph(req) {
+ if (isVisualiserWasmReady()) {
+ const got = callVisualiserWasmJson("meshchatxVisualiserBuildPathGraph", JSON.stringify(req));
+ if (got && Array.isArray(got.nodes) && Array.isArray(got.edges)) {
+ return got;
+ }
+ }
+ return buildPathGraphJs(req);
+}
+
+/**
+ * Full mesh graph (me + interfaces + discovered + announces) via WASM or JS.
+ * @param {object} req
+ */
+export function buildFullGraph(req) {
+ if (isVisualiserWasmReady()) {
+ const got = callVisualiserWasmJson("meshchatxVisualiserBuildFullGraph", JSON.stringify(req));
+ if (got && Array.isArray(got.nodes) && Array.isArray(got.edges)) {
+ return got;
+ }
+ }
+ // Fallback: path graph only, caller still builds me/ifaces in Vue for older paths.
+ const path = buildPathGraphJs({
+ path_table: req.path_table,
+ announces: req.announces,
+ conversations: req.conversations,
+ icon_cache: req.icon_cache,
+ positions: req.positions,
+ hop_max: req.hop_max,
+ search: req.search,
+ dark_mode: req.dark_mode,
+ lod: req.lod,
+ aspects: req.aspects,
+ queue_icons: req.queue_icons,
+ icon_generation: req.icon_generation,
+ });
+ return {
+ ...path,
+ layout_nodes: (path.nodes || []).map((n) => ({
+ id: n.id,
+ x: n.x,
+ y: n.y,
+ mass: n.group === "me" ? 4 : n.group === "interface" ? 2.5 : 1,
+ fixed: n.id === "me",
+ })),
+ layout_edges: (path.edges || []).map((e) => ({
+ from: e.from,
+ to: e.to,
+ length: e.width >= 2 ? 150 : 180,
+ })),
+ };
+}
+
+/**
+ * Settle layout positions in WASM (or no-op passthrough when unavailable).
+ * @param {{ nodes: object[], edges: object[], iterations?: number }} req
+ */
+export function settleLayout(req) {
+ if (isVisualiserWasmReady()) {
+ const got = callVisualiserWasmJson("meshchatxVisualiserLayout", JSON.stringify(req));
+ if (got && got.positions && typeof got.positions === "object") {
+ return got;
+ }
+ }
+ const positions = {};
+ for (const n of req?.nodes || []) {
+ if (n?.id) positions[n.id] = { x: n.x || 0, y: n.y || 0 };
+ }
+ return { positions, iterations: 0 };
+}
+
+/**
+ * @param {object[]} nodes
+ * @param {string} lod
+ * @param {boolean} darkMode
+ * @returns {object[]}
+ */
+export function computeLodUpdatesJs(nodes, lod, darkMode) {
+ if (!Array.isArray(nodes) || nodes.length === 0) {
+ return [];
+ }
+ const fontColor = darkMode ? "#ffffff" : "#000000";
+ const blueBorder = "#3b82f6";
+ const blueBg = darkMode ? "#1e40af" : "#eff6ff";
+ const updates = [];
+
+ for (const node of nodes) {
+ if (!node || !node.id) continue;
+ let next;
+ if (lod === "low") {
+ const isInterface = node.group === "interface";
+ const baseColor = isInterface && node.color ? node.color : nodeColor(blueBorder, blueBg);
+ next = {
+ id: node.id,
+ shape: "dot",
+ size: node.id === "me" ? 15 : 10,
+ font: { size: 0 },
+ color: baseColor,
+ };
+ } else if (lod === "medium") {
+ next = {
+ id: node.id,
+ shape: node._originalShape || "circularImage",
+ size: node._originalSize || (node.id === "me" ? 50 : 25),
+ font: { size: 0 },
+ };
+ } else {
+ next = {
+ id: node.id,
+ shape: node._originalShape || "circularImage",
+ size: node._originalSize || (node.id === "me" ? 50 : 25),
+ font: { size: node.id === "me" ? 16 : 11, color: fontColor },
+ };
+ }
+ const shapeChanged = next.shape != null && next.shape !== node.shape;
+ const sizeChanged = next.size != null && next.size !== node.size;
+ const fontSize = next.font?.size;
+ const fontChanged = fontSize != null && fontSize !== (node.font?.size ?? null);
+ if (shapeChanged || sizeChanged || fontChanged) {
+ updates.push(next);
+ }
+ }
+ return updates;
+}
+
+/**
+ * @param {object[]} nodes
+ * @param {string} lod
+ * @param {boolean} darkMode
+ */
+export function computeLodUpdates(nodes, lod, darkMode) {
+ if (isVisualiserWasmReady()) {
+ const got = callVisualiserWasmJson(
+ "meshchatxVisualiserLODUpdates",
+ JSON.stringify({ nodes, lod, dark_mode: !!darkMode })
+ );
+ if (Array.isArray(got)) {
+ return got;
+ }
+ }
+ return computeLodUpdatesJs(nodes, lod, darkMode);
+}
+
+/**
+ * Map camera scale to LOD level (WASM when ready).
+ * @param {number} scale
+ * @returns {"low"|"medium"|"high"}
+ */
+export function lodLevelFromScale(scale) {
+ if (isVisualiserWasmReady() && typeof globalThis.meshchatxVisualiserLODLevel === "function") {
+ try {
+ const v = globalThis.meshchatxVisualiserLODLevel(scale);
+ if (v === "low" || v === "medium" || v === "high") {
+ return v;
+ }
+ } catch {
+ /* fall through */
+ }
+ }
+ if (scale < 0.2) return "low";
+ if (scale < 0.5) return "medium";
+ return "high";
+}
+
+/** Warm the WASM module early. Safe to ignore the result. */
+export function warmVisualiserWasm() {
+ return preloadVisualiserWasm().catch(() => false);
+}

diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index 1d08be71..ca59d1ce 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -202,6 +202,25 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"app.live_preview",
"app.realtime",
],
+ battery: [
+ "Battery",
+ "Battery saver",
+ "power",
+ "polling",
+ "discovery",
+ "settings.battery.title",
+ "settings.battery.description",
+ "settings.battery.enabled",
+ "settings.battery.disable_visualiser_discovery",
+ "settings.battery.hide_offline_interfaces",
+ "settings.battery.max_visualiser_interfaces",
+ "settings.battery.visualiser_reload_seconds",
+ "settings.battery.disable_visualiser_live_layout",
+ "settings.battery.reduce_background_polling",
+ "settings.battery.reduce_interfaces_discovery",
+ "settings.battery.apply_interface_bitrate_limits",
+ "settings.battery.apply_bitrates_reload",
+ ],
language: [
"i18n",
"app.language",

diff --git a/meshchatx/src/frontend/js/resourceBreakdown.js b/meshchatx/src/frontend/js/resourceBreakdown.js
new file mode 100644
index 00000000..ba0eea1d
--- /dev/null
+++ b/meshchatx/src/frontend/js/resourceBreakdown.js
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+/**
+ * Merge backend resource_breakdown with optional Electron private memory.
+ * Electron processMemoryInfo values are kilobytes.
+ *
+ * @param {Array<{name?: string, rss?: number|null, cpu_percent?: number|null}>|null|undefined} breakdown
+ * @param {{ private?: number, residentSet?: number }|null|undefined} electronMemory
+ * @returns {Array<{name: string, rss: number|null, cpu_percent: number|null}>}
+ */
+export function mergeResourceBreakdown(breakdown, electronMemory) {
+ const rows = Array.isArray(breakdown)
+ ? breakdown
+ .filter((row) => row && typeof row === "object")
+ .map((row) => ({
+ name: String(row.name || "process"),
+ rss: row.rss == null ? null : Number(row.rss),
+ cpu_percent: row.cpu_percent == null ? null : Number(row.cpu_percent),
+ }))
+ : [];
+
+ if (electronMemory && typeof electronMemory === "object") {
+ const kb = Number(electronMemory.private ?? electronMemory.residentSet);
+ if (Number.isFinite(kb) && kb > 0) {
+ rows.push({
+ name: "electron",
+ rss: Math.round(kb * 1024),
+ cpu_percent: null,
+ });
+ }
+ }
+ return rows;
+}
+
+/**
+ * @param {Array<{name: string, rss: number|null, cpu_percent: number|null}>} rows
+ * @returns {{name: string, rss: number|null, cpu_percent: number|null}|null}
+ */
+export function topResourceByRss(rows) {
+ if (!Array.isArray(rows) || rows.length === 0) return null;
+ const scored = rows.filter((r) => r.rss != null && Number.isFinite(r.rss));
+ if (!scored.length) return null;
+ return scored.reduce((best, row) => (row.rss > best.rss ? row : best));
+}
+
+/**
+ * @param {Array<{name: string, rss: number|null, cpu_percent: number|null}>} rows
+ * @returns {{name: string, rss: number|null, cpu_percent: number|null}|null}
+ */
+export function topResourceByCpu(rows) {
+ if (!Array.isArray(rows) || rows.length === 0) return null;
+ const scored = rows.filter((r) => r.cpu_percent != null && Number.isFinite(r.cpu_percent));
+ if (!scored.length) return null;
+ return scored.reduce((best, row) => (row.cpu_percent > best.cpu_percent ? row : best));
+}

diff --git a/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.js b/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.js
new file mode 100644
index 00000000..d569bfe4
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.js
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+import { buildBitrateApplyPayload, loadBatterySaverPrefs, saveBatterySaverPrefs } from "./batterySaverPrefs.js";
+
+/**
+ * Apply configured battery-saver bitrate caps and optionally reload RNS.
+ * @param {{ api?: { get: Function, post: Function }, reload?: boolean }} [opts]
+ * @returns {Promise<{ updated: string[], reloaded: boolean }>}
+ */
+export async function applyBatterySaverBitrateLimits(opts = {}) {
+ const api = opts.api || (typeof window !== "undefined" ? window.api : null);
+ if (!api) {
+ throw new Error("API client unavailable");
+ }
+ const prefs = loadBatterySaverPrefs();
+ if (!prefs.applyInterfaceBitrateLimits) {
+ return { updated: [], reloaded: false };
+ }
+ const limits = prefs.interfaceBitrateLimits || {};
+ if (Object.keys(limits).length === 0) {
+ return { updated: [], reloaded: false };
+ }
+
+ const listResp = await api.get("/api/v1/reticulum/interfaces");
+ const interfaces = listResp?.data?.interfaces || {};
+ const { bitrates, previous } = buildBitrateApplyPayload(interfaces, limits);
+ if (Object.keys(bitrates).length === 0) {
+ return { updated: [], reloaded: false };
+ }
+
+ const reload = opts.reload !== false;
+ const resp = await api.post("/api/v1/reticulum/interfaces/bitrates", {
+ bitrates,
+ reload,
+ });
+ saveBatterySaverPrefs({
+ interfaceBitratePrevious: {
+ ...prefs.interfaceBitratePrevious,
+ ...previous,
+ },
+ });
+ return {
+ updated: resp?.data?.updated || Object.keys(bitrates),
+ reloaded: Boolean(resp?.data?.reloaded),
+ };
+}
+
+/**
+ * Restore bitrates saved before the last apply, then reload RNS.
+ * @param {{ api?: { post: Function }, reload?: boolean }} [opts]
+ */
+export async function restoreBatterySaverBitrateLimits(opts = {}) {
+ const api = opts.api || (typeof window !== "undefined" ? window.api : null);
+ if (!api) {
+ throw new Error("API client unavailable");
+ }
+ const prefs = loadBatterySaverPrefs();
+ const previous = prefs.interfaceBitratePrevious || {};
+ if (Object.keys(previous).length === 0) {
+ return { updated: [], reloaded: false };
+ }
+ const reload = opts.reload !== false;
+ const resp = await api.post("/api/v1/reticulum/interfaces/bitrates", {
+ bitrates: previous,
+ reload,
+ });
+ saveBatterySaverPrefs({ interfaceBitratePrevious: {} });
+ return {
+ updated: resp?.data?.updated || Object.keys(previous),
+ reloaded: Boolean(resp?.data?.reloaded),
+ };
+}

diff --git a/meshchatx/src/frontend/js/settings/batterySaverPrefs.js b/meshchatx/src/frontend/js/settings/batterySaverPrefs.js
new file mode 100644
index 00000000..9cb37539
--- /dev/null
+++ b/meshchatx/src/frontend/js/settings/batterySaverPrefs.js
@@ -0,0 +1,239 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+/**
+ * Battery saver preferences (localStorage).
+ * Safe UI/runtime throttles plus optional forced interface bitrates.
+ */
+
+import GlobalEmitter from "../GlobalEmitter";
+
+export const BATTERY_SAVER_STORAGE_KEY = "meshchatx.batterySaver";
+export const BATTERY_SAVER_CHANGED_EVENT = "battery-saver-prefs-changed";
+
+/** @typedef {object} BatterySaverPrefs
+ * @property {boolean} enabled
+ * @property {boolean} disableVisualiserDiscovery
+ * @property {boolean} hideOfflineInterfaces
+ * @property {number} maxVisualiserInterfaces 0 = unlimited
+ * @property {number} visualiserReloadSeconds 0 = disable auto-reload while saver on
+ * @property {boolean} disableVisualiserLiveLayout
+ * @property {boolean} reduceBackgroundPolling
+ * @property {number} backgroundPollMultiplier
+ * @property {boolean} reduceInterfacesDiscovery
+ * @property {number} interfacesStatsPollSeconds
+ * @property {number} interfacesDiscoveryPollSeconds
+ * @property {boolean} applyInterfaceBitrateLimits
+ * @property {Record<string, number>} interfaceBitrateLimits name -> bps
+ * @property {Record<string, number|null>} interfaceBitratePrevious snapshot before apply
+ */
+
+/** @type {BatterySaverPrefs} */
+export const BATTERY_SAVER_DEFAULTS = Object.freeze({
+ enabled: false,
+ disableVisualiserDiscovery: true,
+ hideOfflineInterfaces: true,
+ maxVisualiserInterfaces: 8,
+ visualiserReloadSeconds: 60,
+ disableVisualiserLiveLayout: true,
+ reduceBackgroundPolling: true,
+ backgroundPollMultiplier: 3,
+ reduceInterfacesDiscovery: true,
+ interfacesStatsPollSeconds: 5,
+ interfacesDiscoveryPollSeconds: 30,
+ applyInterfaceBitrateLimits: false,
+ interfaceBitrateLimits: Object.freeze({}),
+ interfaceBitratePrevious: Object.freeze({}),
+});
+
+/**
+ * @param {unknown} raw
+ * @returns {Record<string, number>}
+ */
+export function normalizeBitrateLimitsMap(raw) {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return {};
+ }
+ /** @type {Record<string, number>} */
+ const out = {};
+ for (const [name, value] of Object.entries(raw)) {
+ const key = String(name || "").trim();
+ if (!key) continue;
+ const bps = Number(value);
+ if (!Number.isFinite(bps) || bps < 0) continue;
+ out[key] = Math.round(bps);
+ }
+ return out;
+}
+
+/**
+ * @param {unknown} raw
+ * @returns {Record<string, number|null>}
+ */
+export function normalizeBitratePreviousMap(raw) {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return {};
+ }
+ /** @type {Record<string, number|null>} */
+ const out = {};
+ for (const [name, value] of Object.entries(raw)) {
+ const key = String(name || "").trim();
+ if (!key) continue;
+ if (value == null || value === "") {
+ out[key] = null;
+ continue;
+ }
+ const bps = Number(value);
+ if (!Number.isFinite(bps) || bps < 0) continue;
+ out[key] = Math.round(bps);
+ }
+ return out;
+}
+
+/**
+ * @param {unknown} raw
+ * @returns {BatterySaverPrefs}
+ */
+export function normalizeBatterySaverPrefs(raw) {
+ const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
+ const mult = Number(src.backgroundPollMultiplier);
+ const maxIfaces = Number(src.maxVisualiserInterfaces);
+ const vizReload = Number(src.visualiserReloadSeconds);
+ const statsPoll = Number(src.interfacesStatsPollSeconds);
+ const discPoll = Number(src.interfacesDiscoveryPollSeconds);
+ return {
+ enabled: src.enabled === true,
+ disableVisualiserDiscovery: src.disableVisualiserDiscovery !== false,
+ hideOfflineInterfaces: src.hideOfflineInterfaces !== false,
+ maxVisualiserInterfaces: Number.isFinite(maxIfaces)
+ ? Math.max(0, Math.min(128, Math.round(maxIfaces)))
+ : BATTERY_SAVER_DEFAULTS.maxVisualiserInterfaces,
+ visualiserReloadSeconds: Number.isFinite(vizReload)
+ ? Math.max(0, Math.min(600, Math.round(vizReload)))
+ : BATTERY_SAVER_DEFAULTS.visualiserReloadSeconds,
+ disableVisualiserLiveLayout: src.disableVisualiserLiveLayout !== false,
+ reduceBackgroundPolling: src.reduceBackgroundPolling !== false,
+ backgroundPollMultiplier: Number.isFinite(mult)
+ ? Math.max(2, Math.min(10, Math.round(mult)))
+ : BATTERY_SAVER_DEFAULTS.backgroundPollMultiplier,
+ reduceInterfacesDiscovery: src.reduceInterfacesDiscovery !== false,
+ interfacesStatsPollSeconds: Number.isFinite(statsPoll)
+ ? Math.max(1, Math.min(120, Math.round(statsPoll)))
+ : BATTERY_SAVER_DEFAULTS.interfacesStatsPollSeconds,
+ interfacesDiscoveryPollSeconds: Number.isFinite(discPoll)
+ ? Math.max(5, Math.min(300, Math.round(discPoll)))
+ : BATTERY_SAVER_DEFAULTS.interfacesDiscoveryPollSeconds,
+ applyInterfaceBitrateLimits: src.applyInterfaceBitrateLimits === true,
+ interfaceBitrateLimits: normalizeBitrateLimitsMap(src.interfaceBitrateLimits),
+ interfaceBitratePrevious: normalizeBitratePreviousMap(src.interfaceBitratePrevious),
+ };
+}
+
+/**
+ * @returns {BatterySaverPrefs}
+ */
+export function loadBatterySaverPrefs() {
+ try {
+ if (typeof localStorage === "undefined") {
+ return { ...BATTERY_SAVER_DEFAULTS, interfaceBitrateLimits: {}, interfaceBitratePrevious: {} };
+ }
+ const raw = localStorage.getItem(BATTERY_SAVER_STORAGE_KEY);
+ if (raw == null || raw === "") {
+ return { ...BATTERY_SAVER_DEFAULTS, interfaceBitrateLimits: {}, interfaceBitratePrevious: {} };
+ }
+ return normalizeBatterySaverPrefs(JSON.parse(raw));
+ } catch {
+ return { ...BATTERY_SAVER_DEFAULTS, interfaceBitrateLimits: {}, interfaceBitratePrevious: {} };
+ }
+}
+
+/**
+ * @param {Partial<BatterySaverPrefs>} patch
+ * @returns {BatterySaverPrefs}
+ */
+export function saveBatterySaverPrefs(patch) {
+ const next = normalizeBatterySaverPrefs({ ...loadBatterySaverPrefs(), ...patch });
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(BATTERY_SAVER_STORAGE_KEY, JSON.stringify(next));
+ }
+ } catch {
+ /* ignore */
+ }
+ GlobalEmitter.emit(BATTERY_SAVER_CHANGED_EVENT, next);
+ return next;
+}
+
+/**
+ * @param {number} baseMs
+ * @param {BatterySaverPrefs} [prefs]
+ * @returns {number}
+ */
+export function applyBackgroundPollInterval(baseMs, prefs = loadBatterySaverPrefs()) {
+ const base = Number(baseMs);
+ if (!Number.isFinite(base) || base <= 0) {
+ return baseMs;
+ }
+ if (!prefs.enabled || !prefs.reduceBackgroundPolling) {
+ return base;
+ }
+ return Math.round(base * prefs.backgroundPollMultiplier);
+}
+
+/**
+ * Effective visualiser auto-reload interval in ms, or null to disable.
+ * @param {number} defaultMs
+ * @param {BatterySaverPrefs} [prefs]
+ * @returns {number|null}
+ */
+export function effectiveVisualiserReloadMs(defaultMs, prefs = loadBatterySaverPrefs()) {
+ if (!prefs.enabled) {
+ return defaultMs;
+ }
+ if (prefs.visualiserReloadSeconds <= 0) {
+ return null;
+ }
+ return prefs.visualiserReloadSeconds * 1000;
+}
+
+/**
+ * @param {BatterySaverPrefs} [prefs]
+ * @returns {string[]}
+ */
+export function activeBatterySaverMeasures(prefs = loadBatterySaverPrefs()) {
+ if (!prefs.enabled) {
+ return [];
+ }
+ const out = [];
+ if (prefs.disableVisualiserDiscovery) out.push("disableVisualiserDiscovery");
+ if (prefs.hideOfflineInterfaces) out.push("hideOfflineInterfaces");
+ if (prefs.maxVisualiserInterfaces > 0) out.push("maxVisualiserInterfaces");
+ if (prefs.visualiserReloadSeconds === 0) out.push("disableVisualiserAutoReload");
+ else if (prefs.visualiserReloadSeconds > 15) out.push("slowVisualiserReload");
+ if (prefs.disableVisualiserLiveLayout) out.push("disableVisualiserLiveLayout");
+ if (prefs.reduceBackgroundPolling) out.push("reduceBackgroundPolling");
+ if (prefs.reduceInterfacesDiscovery) out.push("reduceInterfacesDiscovery");
+ if (prefs.applyInterfaceBitrateLimits && Object.keys(prefs.interfaceBitrateLimits || {}).length > 0) {
+ out.push("applyInterfaceBitrateLimits");
+ }
+ return out;
+}
+
+/**
+ * @param {Record<string, any>} interfacesMap
+ * @param {Record<string, number>} limits
+ * @returns {{ bitrates: Record<string, number>, previous: Record<string, number|null> }}
+ */
+export function buildBitrateApplyPayload(interfacesMap, limits) {
+ const bitrates = {};
+ const previous = {};
+ const src = interfacesMap && typeof interfacesMap === "object" ? interfacesMap : {};
+ const lim = normalizeBitrateLimitsMap(limits);
+ for (const [name, bps] of Object.entries(lim)) {
+ if (!(name in src)) continue;
+ const current = src[name]?.bitrate;
+ const parsed = current == null || current === "" ? null : Number(current);
+ previous[name] = Number.isFinite(parsed) ? Math.round(parsed) : null;
+ bitrates[name] = bps;
+ }
+ return { bitrates, previous };
+}

diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js
index 0f631751..d81c2f27 100644
--- a/meshchatx/src/frontend/js/settings/settingsTabs.js
+++ b/meshchatx/src/frontend/js/settings/settingsTabs.js
@@ -8,7 +8,7 @@ export const SETTINGS_TABS = [
id: "general",
labelKey: "settings.tabs.general",
descriptionKey: "settings.tabs.general_desc",
- sections: ["language", "appearance", "desktop", "android", "shortcuts", "location"],
+ sections: ["language", "appearance", "battery", "desktop", "android", "shortcuts", "location"],
},
{
id: "messages",

diff --git a/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js b/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js
index 3357980b..66f34814 100644
--- a/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js
+++ b/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js
@@ -1,52 +1,99 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
import GlobalEmitter from "../GlobalEmitter";
const KEY_DISABLED = "meshchatx.visualiser.showDisabledInterfaces";
const KEY_DISCOVERED = "meshchatx.visualiser.showDiscoveredInterfaces";
+const KEY_LIVE_LAYOUT = "meshchatx.visualiser.enablePhysics";
+const KEY_AUTO_RELOAD = "meshchatx.visualiser.autoReload";
+
+export const VISUALISER_DISPLAY_PREFS_CHANGED = "visualiser-display-prefs-changed";
/**
- * @returns {{ showDisabledInterfaces: boolean, showDiscoveredInterfaces: boolean }}
+ * @param {string} key
+ * @param {boolean} defaultValue
+ * @returns {boolean}
*/
-export function loadVisualiserDisplayPrefs() {
+function readBool(key, defaultValue) {
try {
- if (typeof localStorage !== "undefined") {
- return {
- showDisabledInterfaces: localStorage.getItem(KEY_DISABLED) === "true",
- showDiscoveredInterfaces: localStorage.getItem(KEY_DISCOVERED) === "true",
- };
+ if (typeof localStorage === "undefined") {
+ return defaultValue;
}
+ const raw = localStorage.getItem(key);
+ if (raw === "true") return true;
+ if (raw === "false") return false;
} catch {
/* localStorage unavailable */
}
- return {
- showDisabledInterfaces: false,
- showDiscoveredInterfaces: false,
- };
+ return defaultValue;
}
/**
+ * @param {string} key
* @param {boolean} val
*/
-export function persistVisualiserShowDisabled(val) {
+function writeBool(key, val) {
try {
if (typeof localStorage !== "undefined") {
- localStorage.setItem(KEY_DISABLED, val ? "true" : "false");
+ localStorage.setItem(key, val ? "true" : "false");
}
} catch {
/* ignore */
}
- GlobalEmitter.emit("visualiser-display-prefs-changed");
+}
+
+/**
+ * @returns {{
+ * showDisabledInterfaces: boolean,
+ * showDiscoveredInterfaces: boolean,
+ * enablePhysics: boolean,
+ * autoReload: boolean,
+ * }}
+ */
+export function loadVisualiserDisplayPrefs() {
+ return {
+ showDisabledInterfaces: readBool(KEY_DISABLED, false),
+ showDiscoveredInterfaces: readBool(KEY_DISCOVERED, false),
+ // Live Layout defaults on when never set.
+ enablePhysics: readBool(KEY_LIVE_LAYOUT, true),
+ autoReload: readBool(KEY_AUTO_RELOAD, false),
+ };
+}
+
+/**
+ * @param {boolean} val
+ */
+export function persistVisualiserShowDisabled(val) {
+ writeBool(KEY_DISABLED, val === true);
+ GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
/**
* @param {boolean} val
*/
export function persistVisualiserShowDiscovered(val) {
- try {
- if (typeof localStorage !== "undefined") {
- localStorage.setItem(KEY_DISCOVERED, val ? "true" : "false");
- }
- } catch {
- /* ignore */
+ writeBool(KEY_DISCOVERED, val === true);
+ GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
+}
+
+/**
+ * @param {boolean} val
+ * @param {{ emit?: boolean }} [opts]
+ */
+export function persistVisualiserLiveLayout(val, opts = {}) {
+ writeBool(KEY_LIVE_LAYOUT, val === true);
+ if (opts.emit !== false) {
+ GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
+ }
+}
+
+/**
+ * @param {boolean} val
+ * @param {{ emit?: boolean }} [opts]
+ */
+export function persistVisualiserAutoReload(val, opts = {}) {
+ writeBool(KEY_AUTO_RELOAD, val === true);
+ if (opts.emit !== false) {
+ GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
- GlobalEmitter.emit("visualiser-display-prefs-changed");
}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index e792e2b1..007f3e7e 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1195,7 +1195,7 @@
"process_threads": "Threads",
"process_uptime": "Laufzeit",
"memory_pressure": "Speicherdruck",
- "memory_pressure_relaxed": "Entspannt (wenig Speicher)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} Pfade verfolgt",
"env_host_battery": "Host-Akku",
"app_battery_use": "Gesch. MeshChatX-Akku",
@@ -1208,7 +1208,27 @@
"app_battery_intensity_low": "niedrig",
"app_battery_intensity_moderate": "mittel",
"app_battery_intensity_high": "hoch",
- "app_battery_intensity_very_high": "sehr hoch"
+ "app_battery_intensity_very_high": "sehr hoch",
+ "battery_saver": "Batteriesparmodus",
+ "battery_saver_off": "Aus",
+ "battery_saver_on": "An",
+ "battery_saver_measures": "Aktive Massnahmen",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Schnittstellen",
@@ -3214,7 +3234,46 @@
},
"failed_update_reticulum_instance": "Fehler beim Aktualisieren der Reticulum-Instanzeinstellungen!",
"keyboard_shortcuts_title": "Tastaturkürzel",
- "keyboard_shortcuts_description": "Schnelle Tastaturaktionen anpassen. Auf Handys standardmäßig eingeklappt."
+ "keyboard_shortcuts_description": "Schnelle Tastaturaktionen anpassen. Auf Handys standardmäßig eingeklappt.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Debug-Protokolle",
@@ -3288,7 +3347,25 @@
"failed_load": "Netzwerkdaten laden fehlgeschlagen",
"max_hops_filter": "Max. Hops",
"show_disabled_interfaces": "Offline-Interfaces anzeigen",
- "show_discovered_interfaces": "Entdeckte Interfaces anzeigen"
+ "show_discovered_interfaces": "Entdeckte Interfaces anzeigen",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "JS-Fallback",
+ "engine_checking": "Pruefen",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Auto-Update",
+ "live_layout": "Live-Layout",
+ "nodes": "Knoten",
+ "links": "Links",
+ "interfaces": "Schnittstellen",
+ "online": "Online",
+ "offline": "Offline",
+ "search_nodes_placeholder": "Knoten suchen ({count})...",
+ "clear_search": "Suche leeren"
},
"banishment": {
"title": "Verbannt",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 4c0b544c..381a0da4 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1143,8 +1143,11 @@
"process_threads": "Threads",
"process_uptime": "Uptime",
"memory_pressure": "Memory pressure",
- "memory_pressure_relaxed": "Relaxed (low memory)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
+ "memory_pressure_normal": "Normal",
"memory_pressure_paths": "{count} paths tracked",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths",
"env_host_battery": "Host battery",
"app_battery_use": "Est. MeshChatX battery",
"app_battery_use_hint": "Estimated from MeshChatX CPU time since start. Not an OS battery attribution.",
@@ -1156,7 +1159,24 @@
"app_battery_intensity_low": "low",
"app_battery_intensity_moderate": "moderate",
"app_battery_intensity_high": "high",
- "app_battery_intensity_very_high": "very high"
+ "app_battery_intensity_very_high": "very high",
+ "battery_saver": "Battery saver",
+ "battery_saver_off": "Off",
+ "battery_saver_on": "On",
+ "battery_saver_measures": "Active measures",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU"
},
"interfaces": {
"title": "Interfaces",
@@ -1748,7 +1768,7 @@
"settings": {
"tabs": {
"general": "General",
- "general_desc": "Language, appearance, platform, maps, and shortcuts",
+ "general_desc": "Language, appearance, battery saver, platform, maps, and shortcuts",
"messages": "Messages",
"messages_desc": "LXMF, stickers, and safety",
"network": "Network",
@@ -1780,6 +1800,45 @@
"share_apk_short_hint": "Opens the system share sheet for this APK.",
"share_apk": "Share app (APK)",
"share_apk_failed": "Could not open the share sheet for the APK.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ },
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Use a faster engine (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -1884,7 +1943,25 @@
"failed_load": "Failed to load network data",
"max_hops_filter": "Max hops",
"show_disabled_interfaces": "Show offline interfaces",
- "show_discovered_interfaces": "Show discovered interfaces"
+ "show_discovered_interfaces": "Show discovered interfaces",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "JS fallback",
+ "engine_checking": "Checking",
+ "engine_wasm_hint": "Graph build and force layout run in Go WebAssembly. Canvas draw stays in the browser",
+ "engine_fallback_hint": "WASM unavailable. Graph build uses JavaScript. Live layout uses vis-network physics",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Auto Update",
+ "live_layout": "Live Layout",
+ "nodes": "Nodes",
+ "links": "Links",
+ "interfaces": "Interfaces",
+ "online": "Online",
+ "offline": "Offline",
+ "search_nodes_placeholder": "Search nodes ({count})...",
+ "clear_search": "Clear search"
},
"banishment": {
"title": "Banished",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index d6211e6f..1de7a896 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1143,7 +1143,7 @@
"process_threads": "Hilos",
"process_uptime": "Tiempo activo",
"memory_pressure": "Presion de memoria",
- "memory_pressure_relaxed": "Relajado (poca memoria)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} rutas rastreadas",
"env_host_battery": "Bateria del host",
"app_battery_use": "Bat. est. MeshChatX",
@@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "baja",
"app_battery_intensity_moderate": "moderada",
"app_battery_intensity_high": "alta",
- "app_battery_intensity_very_high": "muy alta"
+ "app_battery_intensity_very_high": "muy alta",
+ "battery_saver": "Ahorro de bateria",
+ "battery_saver_off": "Desactivado",
+ "battery_saver_on": "Activado",
+ "battery_saver_measures": "Medidas activas",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfaces",
@@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "¡Error al actualizar la configuración de la instancia de Reticulum!",
"keyboard_shortcuts_title": "Atajos de teclado",
- "keyboard_shortcuts_description": "Personaliza acciones rápidas de teclado. Contraído por defecto en móviles."
+ "keyboard_shortcuts_description": "Personaliza acciones rápidas de teclado. Contraído por defecto en móviles.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Registros de depuración",
@@ -1884,7 +1943,25 @@
"failed_load": "Error al cargar datos de red",
"max_hops_filter": "Max Hops",
"show_disabled_interfaces": "Mostrar interfaces offline",
- "show_discovered_interfaces": "Mostrar interfaces descubiertas"
+ "show_discovered_interfaces": "Mostrar interfaces descubiertas",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "Reserva JS",
+ "engine_checking": "Comprobando",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Actualizacion auto",
+ "live_layout": "Diseno en vivo",
+ "nodes": "Nodos",
+ "links": "Enlaces",
+ "interfaces": "Interfaces",
+ "online": "En linea",
+ "offline": "Fuera",
+ "search_nodes_placeholder": "Buscar nodos ({count})...",
+ "clear_search": "Borrar busqueda"
},
"banishment": {
"title": "Desterrados",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index a01d9593..468ce3f0 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1143,7 +1143,7 @@
"process_threads": "Sailkeet",
"process_uptime": "Kayttoaaika",
"memory_pressure": "Muistipaine",
- "memory_pressure_relaxed": "Lievitetty (vahan muistia)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} polkua seurannassa",
"env_host_battery": "Isannan akku",
"app_battery_use": "Arvio MeshChatX-akku",
@@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "matala",
"app_battery_intensity_moderate": "kohtalainen",
"app_battery_intensity_high": "korkea",
- "app_battery_intensity_very_high": "hyvin korkea"
+ "app_battery_intensity_very_high": "hyvin korkea",
+ "battery_saver": "Akunsäästötila",
+ "battery_saver_off": "Pois",
+ "battery_saver_on": "Paalla",
+ "battery_saver_measures": "Aktiiviset toimet",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Sovittimet",
@@ -1810,7 +1830,46 @@
"micron_wasm_update_toast_reverted": "Palautettiin paketin Micron WASM.",
"failed_update_reticulum_instance": "Reticulum-esiintymän asetusten päivitys epäonnistui!",
"keyboard_shortcuts_title": "Pikanäppäimet",
- "keyboard_shortcuts_description": "Mukauta pikanäppäimiä. Puhelimissa oletuksena tiivistetty."
+ "keyboard_shortcuts_description": "Mukauta pikanäppäimiä. Puhelimissa oletuksena tiivistetty.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Vianetsintälokit",
@@ -1884,7 +1943,25 @@
"failed_load": "Verkkodatan lataus epäonnistui",
"max_hops_filter": "Hyppyjen enimmäismäärä",
"show_disabled_interfaces": "Näytä käyttämättömät sovittimet",
- "show_discovered_interfaces": "Näytä havaitut sovittimet"
+ "show_discovered_interfaces": "Näytä havaitut sovittimet",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "JS-varajärjestelmä",
+ "engine_checking": "Tarkistetaan",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Automaattipäivitys",
+ "live_layout": "Elävä asettelu",
+ "nodes": "Solmut",
+ "links": "Linkit",
+ "interfaces": "Liitännät",
+ "online": "Online",
+ "offline": "Offline",
+ "search_nodes_placeholder": "Hae solmuja ({count})...",
+ "clear_search": "Tyhjennä haku"
},
"banishment": {
"title": "Karkotus",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index c0ac74bf..2a5875f9 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1143,7 +1143,7 @@
"process_threads": "Threads",
"process_uptime": "Duree d'activite",
"memory_pressure": "Pression memoire",
- "memory_pressure_relaxed": "Assoupli (memoire basse)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} chemins suivis",
"env_host_battery": "Batterie hote",
"app_battery_use": "Batt. est. MeshChatX",
@@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "faible",
"app_battery_intensity_moderate": "moderee",
"app_battery_intensity_high": "elevee",
- "app_battery_intensity_very_high": "tres elevee"
+ "app_battery_intensity_very_high": "tres elevee",
+ "battery_saver": "Economie de batterie",
+ "battery_saver_off": "Desactive",
+ "battery_saver_on": "Active",
+ "battery_saver_measures": "Mesures actives",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfaces",
@@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "Échec de la mise à jour des paramètres de l'instance Reticulum",
"keyboard_shortcuts_title": "Raccourcis clavier",
- "keyboard_shortcuts_description": "Personnalisez les actions clavier. Replié par défaut sur mobile."
+ "keyboard_shortcuts_description": "Personnalisez les actions clavier. Replié par défaut sur mobile.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Débogues",
@@ -1884,7 +1943,25 @@
"failed_load": "Impossible de charger les données du réseau",
"max_hops_filter": "Max houblon",
"show_disabled_interfaces": "Afficher les interfaces hors ligne",
- "show_discovered_interfaces": "Afficher les interfaces découvertes"
+ "show_discovered_interfaces": "Afficher les interfaces découvertes",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "Repli JS",
+ "engine_checking": "Verification",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "MAJ auto",
+ "live_layout": "Disposition live",
+ "nodes": "Noeuds",
+ "links": "Liens",
+ "interfaces": "Interfaces",
+ "online": "En ligne",
+ "offline": "Hors ligne",
+ "search_nodes_placeholder": "Rechercher des noeuds ({count})...",
+ "clear_search": "Effacer la recherche"
},
"banishment": {
"title": "Interdit",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index efc2c49b..1a286058 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1195,7 +1195,7 @@
"process_threads": "Thread",
"process_uptime": "Tempo di attivita",
"memory_pressure": "Pressione memoria",
- "memory_pressure_relaxed": "Rilassato (poca memoria)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} percorsi tracciati",
"env_host_battery": "Batteria host",
"app_battery_use": "Batt. stim. MeshChatX",
@@ -1208,7 +1208,27 @@
"app_battery_intensity_low": "bassa",
"app_battery_intensity_moderate": "moderata",
"app_battery_intensity_high": "alta",
- "app_battery_intensity_very_high": "molto alta"
+ "app_battery_intensity_very_high": "molto alta",
+ "battery_saver": "Risparmio batteria",
+ "battery_saver_off": "Disattivo",
+ "battery_saver_on": "Attivo",
+ "battery_saver_measures": "Misure attive",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfacce",
@@ -1862,7 +1882,46 @@
},
"failed_update_reticulum_instance": "Impossibile aggiornare le impostazioni dell'istanza Reticulum!",
"keyboard_shortcuts_title": "Scorciatoie da tastiera",
- "keyboard_shortcuts_description": "Personalizza le azioni rapide da tastiera. Compresso di default su telefono."
+ "keyboard_shortcuts_description": "Personalizza le azioni rapide da tastiera. Compresso di default su telefono.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Log di Debug",
@@ -1936,7 +1995,25 @@
"failed_load": "Impossibile caricare i dati di rete",
"max_hops_filter": "Salti max",
"show_disabled_interfaces": "Mostra interfacce offline",
- "show_discovered_interfaces": "Mostra interfacce scoperte"
+ "show_discovered_interfaces": "Mostra interfacce scoperte",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "Fallback JS",
+ "engine_checking": "Verifica",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Aggiornamento auto",
+ "live_layout": "Layout live",
+ "nodes": "Nodi",
+ "links": "Collegamenti",
+ "interfaces": "Interfacce",
+ "online": "Online",
+ "offline": "Offline",
+ "search_nodes_placeholder": "Cerca nodi ({count})...",
+ "clear_search": "Cancella ricerca"
},
"banishment": {
"title": "Esiliati",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 317d8515..ef6862af 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1143,7 +1143,7 @@
"process_threads": "Threads",
"process_uptime": "Uptime",
"memory_pressure": "Geheugendruk",
- "memory_pressure_relaxed": "Versoepeld (weinig geheugen)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} paden gevolgd",
"env_host_battery": "Hostbatterij",
"app_battery_use": "Gesch. MeshChatX-batterij",
@@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "laag",
"app_battery_intensity_moderate": "matig",
"app_battery_intensity_high": "hoog",
- "app_battery_intensity_very_high": "zeer hoog"
+ "app_battery_intensity_very_high": "zeer hoog",
+ "battery_saver": "Batterijbesparing",
+ "battery_saver_off": "Uit",
+ "battery_saver_on": "Aan",
+ "battery_saver_measures": "Actieve maatregelen",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Interfaces",
@@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "Instellingen van Reticulum-instantie bijwerken mislukt!",
"keyboard_shortcuts_title": "Sneltoetsen",
- "keyboard_shortcuts_description": "Pas snelle toetsenbordacties aan. Standaard ingeklapt op telefoons."
+ "keyboard_shortcuts_description": "Pas snelle toetsenbordacties aan. Standaard ingeklapt op telefoons.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Debuglogs",
@@ -1884,7 +1943,25 @@
"failed_load": "Kon netwerkgegevens niet laden",
"max_hops_filter": "Max. hop",
"show_disabled_interfaces": "Offline interfaces tonen",
- "show_discovered_interfaces": "Ontdekte interfaces tonen"
+ "show_discovered_interfaces": "Ontdekte interfaces tonen",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "JS-fallback",
+ "engine_checking": "Controleren",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Auto-update",
+ "live_layout": "Live-layout",
+ "nodes": "Knooppunten",
+ "links": "Links",
+ "interfaces": "Interfaces",
+ "online": "Online",
+ "offline": "Offline",
+ "search_nodes_placeholder": "Zoek knooppunten ({count})...",
+ "clear_search": "Zoekopdracht wissen"
},
"banishment": {
"title": "Verbannen",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index ae0a743a..53a18a8c 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1195,7 +1195,7 @@
"process_threads": "Потоки",
"process_uptime": "Время работы",
"memory_pressure": "Давление памяти",
- "memory_pressure_relaxed": "Ослаблен (мало памяти)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "{count} путей отслеживается",
"env_host_battery": "Батарея хоста",
"app_battery_use": "Оц. батарея MeshChatX",
@@ -1208,7 +1208,27 @@
"app_battery_intensity_low": "низкая",
"app_battery_intensity_moderate": "средняя",
"app_battery_intensity_high": "высокая",
- "app_battery_intensity_very_high": "очень высокая"
+ "app_battery_intensity_very_high": "очень высокая",
+ "battery_saver": "Энергосбережение",
+ "battery_saver_off": "Выкл.",
+ "battery_saver_on": "Вкл.",
+ "battery_saver_measures": "Активные меры",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "Интерфейсы",
@@ -3214,7 +3234,46 @@
},
"failed_update_reticulum_instance": "Не удалось обновить настройки экземпляра Reticulum!",
"keyboard_shortcuts_title": "Горячие клавиши",
- "keyboard_shortcuts_description": "Настройте быстрые клавиши. На телефонах свёрнуто по умолчанию."
+ "keyboard_shortcuts_description": "Настройте быстрые клавиши. На телефонах свёрнуто по умолчанию.",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "Журнал отладки",
@@ -3288,7 +3347,25 @@
"failed_load": "Не удалось загрузить данные сети",
"max_hops_filter": "Макс. хопов",
"show_disabled_interfaces": "Показать отключенные интерфейсы",
- "show_discovered_interfaces": "Показать обнаруженные интерфейсы"
+ "show_discovered_interfaces": "Показать обнаруженные интерфейсы",
+ "refresh": "Refresh",
+ "engine": "Engine",
+ "engine_wasm": "WASM",
+ "engine_fallback": "JS fallback",
+ "engine_checking": "Проверка",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "FPS",
+ "auto_update": "Автообновление",
+ "live_layout": "Живая раскладка",
+ "nodes": "Узлы",
+ "links": "Связи",
+ "interfaces": "Интерфейсы",
+ "online": "Онлайн",
+ "offline": "Офлайн",
+ "search_nodes_placeholder": "Поиск узлов ({count})...",
+ "clear_search": "Очистить поиск"
},
"banishment": {
"title": "Забаненные",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index cb7c321f..59eebabc 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1143,7 +1143,7 @@
"process_threads": "线程",
"process_uptime": "运行时间",
"memory_pressure": "内存压力",
- "memory_pressure_relaxed": "已放宽(低内存)",
+ "memory_pressure_relaxed": "Active (low host RAM)",
"memory_pressure_paths": "已跟踪 {count} 条路径",
"env_host_battery": "主机电池",
"app_battery_use": "MeshChatX 预估耗电",
@@ -1156,7 +1156,27 @@
"app_battery_intensity_low": "低",
"app_battery_intensity_moderate": "中",
"app_battery_intensity_high": "高",
- "app_battery_intensity_very_high": "很高"
+ "app_battery_intensity_very_high": "很高",
+ "battery_saver": "省电模式",
+ "battery_saver_off": "关闭",
+ "battery_saver_on": "开启",
+ "battery_saver_measures": "生效措施",
+ "battery_saver_measure": {
+ "disableVisualiserDiscovery": "Visualiser discovery hidden",
+ "hideOfflineInterfaces": "Offline interfaces hidden",
+ "maxVisualiserInterfaces": "Visualiser interface limit",
+ "disableVisualiserAutoReload": "Visualiser auto-reload off",
+ "slowVisualiserReload": "Slower visualiser reload",
+ "disableVisualiserLiveLayout": "Live layout off",
+ "reduceBackgroundPolling": "Slower background polling",
+ "reduceInterfacesDiscovery": "Slower interface discovery polls",
+ "applyInterfaceBitrateLimits": "Forced interface bitrates"
+ },
+ "top_memory_consumer": "Top memory",
+ "top_cpu_consumer": "Top CPU",
+ "memory_pressure_normal": "Normal",
+ "path_table": "Path table",
+ "path_table_count": "{count} paths"
},
"interfaces": {
"title": "接口",
@@ -1810,7 +1830,46 @@
},
"failed_update_reticulum_instance": "更新 Reticulum 实例设置失败!",
"keyboard_shortcuts_title": "键盘快捷键",
- "keyboard_shortcuts_description": "自定义快捷键操作。手机上默认折叠。"
+ "keyboard_shortcuts_description": "自定义快捷键操作。手机上默认折叠。",
+ "battery": {
+ "eyebrow": "Power",
+ "title": "Battery saver",
+ "description": "Reduce UI polling and visualiser work on battery. Messaging and mesh delivery stay active. Each option below applies only while battery saver is on.",
+ "enabled": "Enable battery saver",
+ "enabled_desc": "Master switch for the power-saving options below.",
+ "options_heading": "When battery saver is on",
+ "disable_visualiser_discovery": "Hide discovered interfaces in visualiser",
+ "disable_visualiser_discovery_desc": "Stops drawing discovered-interface nodes on the network graph.",
+ "hide_offline_interfaces": "Hide offline interfaces in visualiser",
+ "hide_offline_interfaces_desc": "Only show online (and path-table) interfaces on the graph.",
+ "max_visualiser_interfaces": "Max interfaces on visualiser",
+ "max_visualiser_interfaces_desc": "0 means unlimited. Prefer online interfaces when truncating.",
+ "visualiser_reload_seconds": "Visualiser auto-reload interval (seconds)",
+ "visualiser_reload_seconds_desc": "0 disables auto-reload while saver is on. Default without saver is 15s.",
+ "disable_visualiser_live_layout": "Disable visualiser live layout",
+ "disable_visualiser_live_layout_desc": "Turns off continuous layout/physics work on the graph.",
+ "reduce_background_polling": "Slow background status polling",
+ "reduce_background_polling_desc": "Multiplies App shell poll intervals (calls, unread, app info).",
+ "background_poll_multiplier": "Background poll multiplier",
+ "background_poll_multiplier_desc": "2–10. Applied to App shell intervals when the option above is on.",
+ "reduce_interfaces_discovery": "Slow Interfaces page polls",
+ "reduce_interfaces_discovery_desc": "Uses the intervals below for stats and discovered-interface refresh.",
+ "interfaces_stats_poll_seconds": "Interfaces stats poll (seconds)",
+ "interfaces_discovery_poll_seconds": "Discovered interfaces poll (seconds)",
+ "apply_interface_bitrate_limits": "Force interface bitrates",
+ "apply_interface_bitrate_limits_desc": "Write forced bitrate (bps) into selected Reticulum interfaces and reload RNS when applied.",
+ "interface_bitrate_limits_help": "Enter a bitrate in bits per second for each interface you want to cap. Leave blank to skip. Apply writes the config and reloads Reticulum.",
+ "interface_bitrate_limits_empty": "No interfaces found yet.",
+ "interface_bitrate_placeholder": "bps",
+ "apply_bitrates_reload": "Apply bitrates and reload RNS",
+ "restore_bitrates_reload": "Restore previous bitrates and reload",
+ "bitrates_applied": "Updated {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_restored": "Restored {count} interface bitrate(s) and reloaded RNS",
+ "bitrates_none_applied": "No bitrate limits matched current interfaces",
+ "bitrates_none_restored": "No previous bitrates to restore",
+ "bitrates_apply_failed": "Failed to apply interface bitrates",
+ "bitrates_restore_failed": "Failed to restore interface bitrates"
+ }
},
"debug": {
"title": "调试日志",
@@ -1884,7 +1943,25 @@
"failed_load": "加载网络数据失败",
"max_hops_filter": "最大跳数",
"show_disabled_interfaces": "显示离线接口",
- "show_discovered_interfaces": "显示已发现接口"
+ "show_discovered_interfaces": "显示已发现接口",
+ "refresh": "刷新",
+ "engine": "引擎",
+ "engine_wasm": "WASM",
+ "engine_fallback": "JS 回退",
+ "engine_checking": "检测中",
+ "engine_wasm_hint": "Path graph build uses Go WebAssembly",
+ "engine_fallback_hint": "WASM unavailable. Using JavaScript fallback",
+ "engine_checking_hint": "Detecting WebAssembly support",
+ "fps": "帧率",
+ "auto_update": "自动更新",
+ "live_layout": "实时布局",
+ "nodes": "节点",
+ "links": "链路",
+ "interfaces": "接口",
+ "online": "在线",
+ "offline": "离线",
+ "search_nodes_placeholder": "搜索节点 ({count})...",
+ "clear_search": "清除搜索"
},
"banishment": {
"title": "放逐",

diff --git a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
new file mode 100644
index 00000000..1dd35c17
--- /dev/null
+++ b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
@@ -0,0 +1,6 @@
+{
+ "version": "1.0.0",
+ "wasm": "sha384-2R/q2Rz5H8EC/+/n7Tu8nCVP+GoJRAWWHFTUpk9uMwUIsVC/nVb024C0zs4cU512",
+ "wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
+ "wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
+}

diff --git a/package.json b/package.json
index 5ad81c4d..3fca3855 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"scripts": {
"dev": "vite dev",
"watch": "pnpm run build-frontend -- --watch",
- "prebuild-frontend": "node scripts/fetch-micron-wasm.mjs && node scripts/sync-meshchatx-docs.js",
+ "prebuild-frontend": "node scripts/fetch-micron-wasm.mjs && node scripts/build-visualiser-wasm.mjs && node scripts/sync-meshchatx-docs.js",
"predev": "node scripts/sync-meshchatx-docs.js",
"build-frontend": "vite build",
"build-backend": "node scripts/build-backend.js",

diff --git a/scripts/build-visualiser-wasm.mjs b/scripts/build-visualiser-wasm.mjs
new file mode 100644
index 00000000..78818976
--- /dev/null
+++ b/scripts/build-visualiser-wasm.mjs
@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+/**
+ * Builds visualiser-wasm (Go) and copies artifacts into frontend public vendor/.
+ * Writes integrity.json with SHA-384 SRI hashes.
+ * Safe offline: if Go is missing, exits 0 when VISUALISER_WASM_SKIP=1.
+ */
+import fs from "fs";
+import path from "path";
+import crypto from "crypto";
+import { spawnSync } from "child_process";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = path.resolve(__dirname, "..");
+const GO_MOD_DIR = path.join(REPO_ROOT, "visualiser-wasm");
+const OUT_DIR = path.join(REPO_ROOT, "meshchatx", "src", "frontend", "public", "vendor", "visualiser-wasm");
+const WASM_NAME = "visualiser.wasm";
+const EXEC_NAME = "wasm_exec.js";
+const VERSION = "1.0.0";
+
+function computeSri(buf) {
+ return `sha384-${crypto.createHash("sha384").update(buf).digest("base64")}`;
+}
+
+function findWasmExec() {
+ const fromEnv = process.env.VISUALISER_GO_WASM_EXEC;
+ if (fromEnv && fs.existsSync(fromEnv)) {
+ return fromEnv;
+ }
+ const goEnv = spawnSync("go", ["env", "GOROOT"], { encoding: "utf8" });
+ if (goEnv.status !== 0) {
+ return null;
+ }
+ const root = goEnv.stdout.trim();
+ const candidates = [
+ path.join(root, "lib", "wasm", "wasm_exec.js"),
+ path.join(root, "misc", "wasm", "wasm_exec.js"),
+ ];
+ for (const c of candidates) {
+ if (fs.existsSync(c)) {
+ return c;
+ }
+ }
+ return null;
+}
+
+function main() {
+ if (process.env.VISUALISER_WASM_SKIP === "1") {
+ console.log("build-visualiser-wasm: VISUALISER_WASM_SKIP=1, skipping.");
+ process.exit(0);
+ }
+
+ if (!fs.existsSync(path.join(GO_MOD_DIR, "go.mod"))) {
+ console.warn("build-visualiser-wasm: visualiser-wasm/go.mod missing, skipping.");
+ process.exit(0);
+ }
+
+ const goCheck = spawnSync("go", ["version"], { encoding: "utf8" });
+ if (goCheck.status !== 0) {
+ if (process.env.MESHCHATX_OFFLINE_BUILD === "1") {
+ const wasmPath = path.join(OUT_DIR, WASM_NAME);
+ const execPath = path.join(OUT_DIR, EXEC_NAME);
+ if (fs.existsSync(wasmPath) && fs.existsSync(execPath)) {
+ console.log("build-visualiser-wasm: go missing but artifacts present (offline).");
+ process.exit(0);
+ }
+ console.error("build-visualiser-wasm: MESHCHATX_OFFLINE_BUILD=1 but artifacts missing and go unavailable.");
+ process.exit(1);
+ }
+ console.warn("build-visualiser-wasm: go not found, skipping (JS fallback will be used).");
+ process.exit(0);
+ }
+
+ fs.mkdirSync(OUT_DIR, { recursive: true });
+ const wasmOut = path.join(OUT_DIR, WASM_NAME);
+ const build = spawnSync("go", ["build", "-trimpath", "-ldflags=-s -w", "-o", wasmOut, "./cmd/wasm"], {
+ cwd: GO_MOD_DIR,
+ env: { ...process.env, GOOS: "js", GOARCH: "wasm" },
+ encoding: "utf8",
+ });
+ if (build.status !== 0) {
+ console.error(build.stderr || build.stdout || "go build failed");
+ process.exit(1);
+ }
+
+ const execSrc = findWasmExec();
+ if (!execSrc) {
+ console.error("build-visualiser-wasm: wasm_exec.js not found under GOROOT");
+ process.exit(1);
+ }
+ const execOut = path.join(OUT_DIR, EXEC_NAME);
+ fs.copyFileSync(execSrc, execOut);
+
+ const wasmBuf = fs.readFileSync(wasmOut);
+ const execBuf = fs.readFileSync(execOut);
+ const integrity = {
+ version: VERSION,
+ wasm: computeSri(wasmBuf),
+ wasmExec: computeSri(execBuf),
+ wasmExecSource: execSrc,
+ };
+ fs.writeFileSync(path.join(OUT_DIR, "integrity.json"), JSON.stringify(integrity, null, 2) + "\n");
+ console.log(`build-visualiser-wasm: OK (${wasmBuf.length} bytes WASM, SRI written to vendor/visualiser-wasm/)`);
+}
+
+main();

diff --git a/tests/backend/test_process_resource_breakdown.py b/tests/backend/test_process_resource_breakdown.py
new file mode 100644
index 00000000..9196e8e6
--- /dev/null
+++ b/tests/backend/test_process_resource_breakdown.py
@@ -0,0 +1,51 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend.process_resource_breakdown import (
+ build_resource_breakdown,
+ top_by_cpu,
+ top_by_rss,
+)
+
+
+class _Mem:
+ def __init__(self, rss):
+ self.rss = rss
+
+
+class _FakeProc:
+ def __init__(self, name, rss, cpu=1.0, children=None):
+ self._name = name
+ self._rss = rss
+ self._cpu = cpu
+ self._children = children or []
+ self.pid = abs(hash(name)) % 100000
+
+ def name(self):
+ return self._name
+
+ def memory_info(self):
+ return _Mem(self._rss)
+
+ def cpu_percent(self, interval=None):
+ return self._cpu
+
+ def children(self, recursive=True):
+ return list(self._children)
+
+
+def test_build_resource_breakdown_orders_by_rss():
+ child_big = _FakeProc("bot", 80_000_000, cpu=20.0)
+ child_small = _FakeProc("helper", 5_000_000, cpu=2.0)
+ parent = _FakeProc("python", 40_000_000, cpu=5.0, children=[child_big, child_small])
+
+ rows = build_resource_breakdown(parent)
+ assert rows[0]["name"] == "child:bot"
+ assert rows[0]["rss"] == 80_000_000
+ assert top_by_rss(rows)["name"] == "child:bot"
+ assert top_by_cpu(rows)["name"] == "child:bot"
+
+
+def test_build_resource_breakdown_handles_none():
+ assert build_resource_breakdown(None) == []
+ assert top_by_rss([]) is None
+ assert top_by_cpu([]) is None

diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index f1a9e047..14a30473 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -20,6 +20,7 @@ describe("AboutPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
+ localStorage.clear();
axiosMock = {
get: vi.fn().mockImplementation(() => Promise.resolve({ data: {} })),
post: vi.fn().mockImplementation(() => Promise.resolve({ data: {} })),
@@ -541,6 +542,10 @@ describe("AboutPage.vue", () => {
confidence: "estimate",
method: "cpu_time",
},
+ resource_breakdown: [
+ { name: "backend", rss: 128 * 1024 * 1024, cpu_percent: 2.5 },
+ { name: "child:bot", rss: 64 * 1024 * 1024, cpu_percent: 12.0 },
+ ],
reticulum_stats: {
memory_cleanup: { path_table_size: 42, sqlite_relaxed: false },
},
@@ -562,6 +567,12 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("about.usage_insights");
expect(wrapper.text()).toContain("about.app_battery_use");
+ expect(wrapper.text()).toContain("about.battery_saver");
+ expect(wrapper.text()).toContain("about.battery_saver_off");
+ expect(wrapper.text()).toContain("about.top_memory_consumer");
+ expect(wrapper.text()).toContain("about.top_cpu_consumer");
+ expect(wrapper.vm.topMemoryConsumerLabel).toContain("backend");
+ expect(wrapper.vm.topCpuConsumerLabel).toContain("child:bot");
expect(wrapper.vm.batteryUsageLabel).toContain("0.4%/hr");
expect(wrapper.text()).toContain("about.memory_rss");
expect(wrapper.text()).toContain("about.process_cpu");
@@ -569,4 +580,37 @@ describe("AboutPage.vue", () => {
expect(wrapper.vm.showHostBattery).toBe(false);
expect(wrapper.text()).not.toContain("about.env_battery");
});
+
+ it("shows battery saver active measures when enabled", async () => {
+ const { saveBatterySaverPrefs } = await import("../../meshchatx/src/frontend/js/settings/batterySaverPrefs.js");
+ saveBatterySaverPrefs({ enabled: true });
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: "1.0.0",
+ host_platform: "linux",
+ memory_usage: { rss: 1, vms: 1 },
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
+ if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
+ if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
+ return Promise.reject(new Error("Not found"));
+ });
+
+ const wrapper = mountAboutPage();
+ await vi.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.getAppInfo();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("about.battery_saver_on");
+ expect(wrapper.text()).toContain("about.battery_saver_measures");
+ expect(wrapper.vm.batterySaverActiveMeasures.length).toBeGreaterThan(0);
+ });
});

diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index 1993f6af..4dc75612 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -157,6 +157,11 @@ describe("NetworkVisualiser.vue", () => {
"visualiser.reticulum_mesh": "Reticulum Mesh",
"visualiser.total_nodes": "Nodes",
"visualiser.total_edges": "Links",
+ "visualiser.nodes": "Nodes",
+ "visualiser.links": "Links",
+ "visualiser.engine_wasm": "WASM",
+ "visualiser.engine_fallback": "JS fallback",
+ "visualiser.engine_checking": "Checking",
};
return translations[msg] || msg;
},
@@ -167,6 +172,7 @@ describe("NetworkVisualiser.vue", () => {
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
props: ["modelValue"],
},
+ MaterialDesignIcon: true,
},
},
});

diff --git a/tests/frontend/NetworkVisualiserToolbar.test.js b/tests/frontend/NetworkVisualiserToolbar.test.js
new file mode 100644
index 00000000..47706e93
--- /dev/null
+++ b/tests/frontend/NetworkVisualiserToolbar.test.js
@@ -0,0 +1,56 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect } from "vitest";
+import NetworkVisualiserToolbar from "@/components/network-visualiser/internal/NetworkVisualiserToolbar.vue";
+
+describe("NetworkVisualiserToolbar", () => {
+ const mountToolbar = (props = {}) =>
+ mount(NetworkVisualiserToolbar, {
+ props: {
+ isShowingControls: true,
+ nodeCount: 12,
+ edgeCount: 8,
+ onlineInterfaceCount: 2,
+ offlineInterfaceCount: 1,
+ engineMode: "wasm",
+ fps: 58,
+ ...props,
+ },
+ global: {
+ mocks: { $t: (k, v) => (v ? `${k}:${JSON.stringify(v)}` : k) },
+ stubs: {
+ Toggle: true,
+ MaterialDesignIcon: {
+ props: ["iconName"],
+ template: `<span class="mdi-stub" :data-icon="iconName"></span>`,
+ },
+ },
+ },
+ });
+
+ it("shows WASM engine label and FPS", () => {
+ const wrapper = mountToolbar();
+ expect(wrapper.text()).toContain("visualiser.engine_wasm");
+ expect(wrapper.text()).toContain("58");
+ expect(wrapper.text()).toContain("visualiser.fps");
+ });
+
+ it("shows JS fallback engine label", () => {
+ const wrapper = mountToolbar({ engineMode: "fallback", fps: 0 });
+ expect(wrapper.text()).toContain("visualiser.engine_fallback");
+ expect(wrapper.text()).toContain("--");
+ });
+
+ it("uses MDI magnify for search and refresh for update button", () => {
+ const wrapper = mountToolbar({ isUpdating: false, isLoading: false });
+ const icons = wrapper.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
+ expect(icons).toContain("magnify");
+ expect(icons).toContain("refresh");
+ expect(icons).not.toContain(undefined);
+ });
+
+ it("uses loading icon while updating", () => {
+ const wrapper = mountToolbar({ isUpdating: true });
+ const icons = wrapper.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
+ expect(icons).toContain("loading");
+ });
+});

diff --git a/tests/frontend/SriIntegrity.test.js b/tests/frontend/SriIntegrity.test.js
index 9fee5293..04389ed3 100644
--- a/tests/frontend/SriIntegrity.test.js
+++ b/tests/frontend/SriIntegrity.test.js
@@ -45,6 +45,36 @@ describe("SRI (Subresource Integrity) Verification", () => {
});
});
+ describe("visualiser-wasm", () => {
+ it("has valid integrity.json with matching SRI hashes when built", () => {
+ const integrityPath = path.join(
+ REPO_ROOT,
+ "meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json"
+ );
+ const wasmPath = path.join(
+ REPO_ROOT,
+ "meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm"
+ );
+ const execPath = path.join(REPO_ROOT, "meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js");
+
+ if (!existsSync(integrityPath)) {
+ return;
+ }
+
+ const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
+ expect(integrity.version).toBeTruthy();
+ expect(integrity.wasm).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+ expect(integrity.wasmExec).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
+
+ if (existsSync(wasmPath)) {
+ expect(computeSri384(wasmPath)).toBe(integrity.wasm);
+ }
+ if (existsSync(execPath)) {
+ expect(computeSri384(execPath)).toBe(integrity.wasmExec);
+ }
+ });
+ });
+
describe("Codec2 Emscripten", () => {
it("has valid integrity.json with all required files", () => {
const integrityPath = path.join(

diff --git a/tests/frontend/VisualiserWasmLoader.test.js b/tests/frontend/VisualiserWasmLoader.test.js
new file mode 100644
index 00000000..e4fc0d6b
--- /dev/null
+++ b/tests/frontend/VisualiserWasmLoader.test.js
@@ -0,0 +1,57 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ isVisualiserWasmBundled,
+ preloadVisualiserWasm,
+ isVisualiserWasmReady,
+ callVisualiserWasmJson,
+} from "@/js/VisualiserWasmLoader.js";
+
+describe("VisualiserWasmLoader", () => {
+ beforeEach(() => {
+ document.getElementById("meshchatx-visualiser-wasm-exec")?.remove();
+ delete globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__;
+ delete globalThis.meshchatxVisualiserBuildPathGraph;
+ delete globalThis.meshchatxVisualiserPathHashes;
+ delete globalThis.meshchatxVisualiserDedupeIcons;
+ delete globalThis.Go;
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ document.getElementById("meshchatx-visualiser-wasm-exec")?.remove();
+ });
+
+ it("reports bundled flag from test override", () => {
+ globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = false;
+ expect(isVisualiserWasmBundled()).toBe(false);
+ globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = true;
+ expect(isVisualiserWasmBundled()).toBe(true);
+ });
+
+ it("preloadVisualiserWasm resolves false when not bundled", async () => {
+ globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = false;
+ await expect(preloadVisualiserWasm()).resolves.toBe(false);
+ });
+
+ it("preloadVisualiserWasm resolves false when WebAssembly is unavailable", async () => {
+ globalThis.__MESHCHATX_TEST_VISUALISER_WASM_BUNDLED__ = true;
+ vi.stubGlobal("WebAssembly", undefined);
+ await expect(preloadVisualiserWasm()).resolves.toBe(false);
+ expect(isVisualiserWasmReady()).toBe(false);
+ });
+
+ it("callVisualiserWasmJson returns null when export missing", () => {
+ expect(callVisualiserWasmJson("meshchatxVisualiserBuildPathGraph", "{}")).toBeNull();
+ });
+
+ it("callVisualiserWasmJson parses JSON string results", () => {
+ globalThis.meshchatxVisualiserPathHashes = () => JSON.stringify(["aa", "bb"]);
+ expect(callVisualiserWasmJson("meshchatxVisualiserPathHashes", "[]", 4)).toEqual(["aa", "bb"]);
+ });
+
+ it("callVisualiserWasmJson returns null on export error object", () => {
+ globalThis.meshchatxVisualiserPathHashes = () => ({ ok: false, error: "bad" });
+ expect(callVisualiserWasmJson("meshchatxVisualiserPathHashes", "[]")).toBeNull();
+ });
+});

diff --git a/tests/frontend/batterySaverBitrateApply.test.js b/tests/frontend/batterySaverBitrateApply.test.js
new file mode 100644
index 00000000..6f01cab3
--- /dev/null
+++ b/tests/frontend/batterySaverBitrateApply.test.js
@@ -0,0 +1,59 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { buildBitrateApplyPayload, saveBatterySaverPrefs } from "@/js/settings/batterySaverPrefs.js";
+import {
+ applyBatterySaverBitrateLimits,
+ restoreBatterySaverBitrateLimits,
+} from "@/js/settings/batterySaverBitrateApply.js";
+
+describe("batterySaverBitrateApply", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("buildBitrateApplyPayload snapshots previous bitrates", () => {
+ const { bitrates, previous } = buildBitrateApplyPayload(
+ { LoRa: { bitrate: "5000" }, TCP: { bitrate: null } },
+ { LoRa: 1200, Missing: 100 }
+ );
+ expect(bitrates).toEqual({ LoRa: 1200 });
+ expect(previous).toEqual({ LoRa: 5000 });
+ });
+
+ it("applyBatterySaverBitrateLimits posts bitrates and reloads", async () => {
+ saveBatterySaverPrefs({
+ applyInterfaceBitrateLimits: true,
+ interfaceBitrateLimits: { LoRa: 1200 },
+ });
+ const api = {
+ get: vi.fn().mockResolvedValue({
+ data: { interfaces: { LoRa: { bitrate: "5000", type: "RNodeInterface" } } },
+ }),
+ post: vi.fn().mockResolvedValue({
+ data: { updated: ["LoRa"], reloaded: true },
+ }),
+ };
+ const result = await applyBatterySaverBitrateLimits({ api, reload: true });
+ expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/bitrates", {
+ bitrates: { LoRa: 1200 },
+ reload: true,
+ });
+ expect(result).toEqual({ updated: ["LoRa"], reloaded: true });
+ });
+
+ it("restoreBatterySaverBitrateLimits restores previous map", async () => {
+ saveBatterySaverPrefs({
+ interfaceBitratePrevious: { LoRa: 5000 },
+ });
+ const api = {
+ post: vi.fn().mockResolvedValue({
+ data: { updated: ["LoRa"], reloaded: true },
+ }),
+ };
+ const result = await restoreBatterySaverBitrateLimits({ api, reload: true });
+ expect(api.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/bitrates", {
+ bitrates: { LoRa: 5000 },
+ reload: true,
+ });
+ expect(result.updated).toEqual(["LoRa"]);
+ });
+});

diff --git a/tests/frontend/batterySaverPrefs.test.js b/tests/frontend/batterySaverPrefs.test.js
new file mode 100644
index 00000000..55e10db3
--- /dev/null
+++ b/tests/frontend/batterySaverPrefs.test.js
@@ -0,0 +1,112 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+ BATTERY_SAVER_DEFAULTS,
+ loadBatterySaverPrefs,
+ saveBatterySaverPrefs,
+ normalizeBatterySaverPrefs,
+ applyBackgroundPollInterval,
+ effectiveVisualiserReloadMs,
+ activeBatterySaverMeasures,
+ BATTERY_SAVER_STORAGE_KEY,
+} from "@/js/settings/batterySaverPrefs.js";
+
+describe("batterySaverPrefs", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("loads defaults when empty", () => {
+ const loaded = loadBatterySaverPrefs();
+ expect(loaded.enabled).toBe(false);
+ expect(loaded.applyInterfaceBitrateLimits).toBe(false);
+ expect(loaded.interfaceBitrateLimits).toEqual({});
+ });
+
+ it("round-trips prefs through localStorage", () => {
+ const saved = saveBatterySaverPrefs({
+ enabled: true,
+ maxVisualiserInterfaces: 4,
+ visualiserReloadSeconds: 90,
+ backgroundPollMultiplier: 4,
+ applyInterfaceBitrateLimits: true,
+ interfaceBitrateLimits: { LoRa: 1200 },
+ });
+ expect(saved.enabled).toBe(true);
+ expect(saved.maxVisualiserInterfaces).toBe(4);
+ expect(saved.interfaceBitrateLimits.LoRa).toBe(1200);
+ expect(JSON.parse(localStorage.getItem(BATTERY_SAVER_STORAGE_KEY)).enabled).toBe(true);
+ expect(loadBatterySaverPrefs().visualiserReloadSeconds).toBe(90);
+ });
+
+ it("clamps numeric fields", () => {
+ const n = normalizeBatterySaverPrefs({
+ enabled: true,
+ maxVisualiserInterfaces: 999,
+ backgroundPollMultiplier: 1,
+ visualiserReloadSeconds: -5,
+ interfacesStatsPollSeconds: 0,
+ interfacesDiscoveryPollSeconds: 1,
+ });
+ expect(n.maxVisualiserInterfaces).toBe(128);
+ expect(n.backgroundPollMultiplier).toBe(2);
+ expect(n.visualiserReloadSeconds).toBe(0);
+ expect(n.interfacesStatsPollSeconds).toBe(1);
+ expect(n.interfacesDiscoveryPollSeconds).toBe(5);
+ });
+
+ it("applyBackgroundPollInterval only scales when enabled", () => {
+ expect(applyBackgroundPollInterval(1000, { ...BATTERY_SAVER_DEFAULTS, enabled: false })).toBe(1000);
+ expect(
+ applyBackgroundPollInterval(1000, {
+ ...BATTERY_SAVER_DEFAULTS,
+ enabled: true,
+ reduceBackgroundPolling: true,
+ backgroundPollMultiplier: 3,
+ })
+ ).toBe(3000);
+ });
+
+ it("effectiveVisualiserReloadMs disables or slows auto-reload", () => {
+ expect(effectiveVisualiserReloadMs(15000, { ...BATTERY_SAVER_DEFAULTS, enabled: false })).toBe(15000);
+ expect(
+ effectiveVisualiserReloadMs(15000, {
+ ...BATTERY_SAVER_DEFAULTS,
+ enabled: true,
+ visualiserReloadSeconds: 0,
+ })
+ ).toBeNull();
+ expect(
+ effectiveVisualiserReloadMs(15000, {
+ ...BATTERY_SAVER_DEFAULTS,
+ enabled: true,
+ visualiserReloadSeconds: 60,
+ })
+ ).toBe(60000);
+ });
+
+ it("activeBatterySaverMeasures lists enabled knobs", () => {
+ expect(activeBatterySaverMeasures({ ...BATTERY_SAVER_DEFAULTS, enabled: false })).toEqual([]);
+ const measures = activeBatterySaverMeasures({
+ ...BATTERY_SAVER_DEFAULTS,
+ enabled: true,
+ interfaceBitrateLimits: {},
+ interfaceBitratePrevious: {},
+ });
+ expect(measures).toContain("disableVisualiserDiscovery");
+ expect(measures).toContain("reduceBackgroundPolling");
+ expect(measures).not.toContain("applyInterfaceBitrateLimits");
+ expect(
+ activeBatterySaverMeasures({
+ ...BATTERY_SAVER_DEFAULTS,
+ enabled: true,
+ applyInterfaceBitrateLimits: true,
+ interfaceBitrateLimits: { A: 1000 },
+ interfaceBitratePrevious: {},
+ })
+ ).toContain("applyInterfaceBitrateLimits");
+ });
+});

diff --git a/tests/frontend/networkVisualiserCache.test.js b/tests/frontend/networkVisualiserCache.test.js
new file mode 100644
index 00000000..de4f8dc4
--- /dev/null
+++ b/tests/frontend/networkVisualiserCache.test.js
@@ -0,0 +1,129 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ loadVisualiserCache,
+ saveVisualiserCache,
+ clearVisualiserCache,
+ resetVisualiserCacheDbHandle,
+} from "@/js/networkVisualiserCache.js";
+
+function mockIndexedDb() {
+ const stores = new Map();
+ const fakeDb = {
+ objectStoreNames: {
+ contains: (name) => name === "snapshots",
+ },
+ transaction(storeNames, mode) {
+ const storeName = Array.isArray(storeNames) ? storeNames[0] : storeNames;
+ if (!stores.has(storeName)) stores.set(storeName, new Map());
+ const data = stores.get(storeName);
+ const store = {
+ get(key) {
+ const req = {};
+ queueMicrotask(() => {
+ req.result = data.get(key);
+ req.onsuccess?.();
+ });
+ return req;
+ },
+ put(row) {
+ data.set(row.identityHash, row);
+ const req = {};
+ queueMicrotask(() => req.onsuccess?.());
+ return req;
+ },
+ delete(key) {
+ data.delete(key);
+ const req = {};
+ queueMicrotask(() => req.onsuccess?.());
+ return req;
+ },
+ };
+ const tx = {
+ objectStore: () => store,
+ oncomplete: null,
+ onerror: null,
+ };
+ queueMicrotask(() => tx.oncomplete?.());
+ return tx;
+ },
+ };
+ const idb = {
+ open() {
+ const req = {
+ result: fakeDb,
+ onupgradeneeded: null,
+ onsuccess: null,
+ onerror: null,
+ };
+ queueMicrotask(() => {
+ req.onupgradeneeded?.({ target: req });
+ req.onsuccess?.({ target: req });
+ });
+ return req;
+ },
+ };
+ return idb;
+}
+
+describe("networkVisualiserCache", () => {
+ beforeEach(() => {
+ resetVisualiserCacheDbHandle();
+ vi.stubGlobal("indexedDB", mockIndexedDb());
+ });
+
+ afterEach(() => {
+ resetVisualiserCacheDbHandle();
+ vi.unstubAllGlobals();
+ });
+
+ it("round-trips path table announces and positions for one identity", async () => {
+ const ok = await saveVisualiserCache({
+ identityHash: "abc123",
+ pathTable: [{ hash: "n1", hops: 1, interface: "eth0" }],
+ announces: { n1: { destination_hash: "n1", aspect: "lxmf.delivery" } },
+ positions: { n1: { x: 10, y: 20 } },
+ });
+ expect(ok).toBe(true);
+ const loaded = await loadVisualiserCache("abc123");
+ expect(loaded.pathTable).toHaveLength(1);
+ expect(loaded.announces.n1.aspect).toBe("lxmf.delivery");
+ expect(loaded.positions.n1).toEqual({ x: 10, y: 20 });
+ });
+
+ it("isolates cache by identity hash", async () => {
+ await saveVisualiserCache({
+ identityHash: "id-a",
+ pathTable: [{ hash: "a" }],
+ announces: {},
+ positions: {},
+ });
+ await saveVisualiserCache({
+ identityHash: "id-b",
+ pathTable: [{ hash: "b" }],
+ announces: {},
+ positions: {},
+ });
+ expect((await loadVisualiserCache("id-a")).pathTable[0].hash).toBe("a");
+ expect((await loadVisualiserCache("id-b")).pathTable[0].hash).toBe("b");
+ });
+
+ it("clearVisualiserCache removes a snapshot", async () => {
+ await saveVisualiserCache({
+ identityHash: "gone",
+ pathTable: [{ hash: "x" }],
+ announces: {},
+ positions: {},
+ });
+ await clearVisualiserCache("gone");
+ expect(await loadVisualiserCache("gone")).toBeNull();
+ });
+
+ it("returns null when IndexedDB is unavailable", async () => {
+ resetVisualiserCacheDbHandle();
+ vi.stubGlobal("indexedDB", undefined);
+ expect(await loadVisualiserCache("x")).toBeNull();
+ expect(await saveVisualiserCache({ identityHash: "x", pathTable: [], announces: {}, positions: {} })).toBe(
+ false
+ );
+ });
+});

diff --git a/tests/frontend/networkVisualiserPerf.test.js b/tests/frontend/networkVisualiserPerf.test.js
index c8a41323..3cb8e99a 100644
--- a/tests/frontend/networkVisualiserPerf.test.js
+++ b/tests/frontend/networkVisualiserPerf.test.js
@@ -1,13 +1,30 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
ANNOUNCE_HASH_CHUNK_SIZE,
VIZ_ANNOUNCE_ASPECTS,
+ buildPathGraph,
+ buildPathGraphJs,
+ computeLodUpdatesJs,
dedupeIconQueueEntries,
+ dedupeIconQueueEntriesJs,
+ lodLevelFromScale,
pathHashesWithinHopFilter,
+ pathHashesWithinHopFilterJs,
pickAdaptiveFetchConcurrency,
} from "@/js/networkVisualiserPerf.js";
describe("networkVisualiserPerf", () => {
+ beforeEach(() => {
+ delete globalThis.meshchatxVisualiserPathHashes;
+ delete globalThis.meshchatxVisualiserDedupeIcons;
+ delete globalThis.meshchatxVisualiserBuildPathGraph;
+ delete globalThis.meshchatxVisualiserLODLevel;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it("exports visualiser constants", () => {
expect(VIZ_ANNOUNCE_ASPECTS).toEqual(["lxmf.delivery", "nomadnetwork.node"]);
expect(ANNOUNCE_HASH_CHUNK_SIZE).toBe(500);
@@ -20,7 +37,7 @@ describe("networkVisualiserPerf", () => {
{ hash: "cc", hops: 5 },
{ hash: "dd", hops: null },
];
- expect(pathHashesWithinHopFilter(pathTable, 4).sort()).toEqual(["aa", "bb"]);
+ expect(pathHashesWithinHopFilterJs(pathTable, 4).sort()).toEqual(["aa", "bb"]);
expect(pathHashesWithinHopFilter(pathTable, null).sort()).toEqual(["aa", "bb", "cc"]);
});
@@ -30,13 +47,72 @@ describe("networkVisualiserPerf", () => {
{ nodeId: "n2", cacheKey: "k1", iconName: "a", fg: "#000", bg: "#fff", size: 64, generation: 1 },
{ nodeId: "n3", cacheKey: "k2", iconName: "b", fg: "#111", bg: "#eee", size: 64, generation: 1 },
];
- const out = dedupeIconQueueEntries(queue);
+ const out = dedupeIconQueueEntriesJs(queue);
expect(out).toHaveLength(2);
expect(out.find((x) => x.cacheKey === "k1")?.nodeIds).toEqual(["n1", "n2"]);
- expect(out.every((x) => x._seen === undefined)).toBe(true);
+ expect(dedupeIconQueueEntries(queue)).toHaveLength(2);
});
it("pickAdaptiveFetchConcurrency returns a positive integer", () => {
expect(pickAdaptiveFetchConcurrency()).toBeGreaterThanOrEqual(2);
});
+
+ it("buildPathGraphJs filters hops and builds nodes/edges", () => {
+ const res = buildPathGraphJs({
+ path_table: [
+ { hash: "aa", interface: "eth0", hops: 1 },
+ { hash: "bb", interface: "eth0", hops: 9 },
+ ],
+ announces: {
+ aa: {
+ destination_hash: "aa",
+ aspect: "lxmf.delivery",
+ display_name: "Alice",
+ last_seen: "now",
+ },
+ bb: {
+ destination_hash: "bb",
+ aspect: "lxmf.delivery",
+ display_name: "Far",
+ last_seen: "now",
+ },
+ },
+ positions: { eth0: { x: 10, y: 20 } },
+ hop_max: 4,
+ dark_mode: false,
+ lod: "high",
+ });
+ expect(res.nodes).toHaveLength(1);
+ expect(res.edges).toHaveLength(1);
+ expect(res.nodes[0].id).toBe("aa");
+ expect(res.edges[0].width).toBe(2.5);
+ expect(buildPathGraph({ path_table: [], announces: {} }).nodes).toEqual([]);
+ });
+
+ it("computeLodUpdatesJs and lodLevelFromScale work without WASM", () => {
+ expect(lodLevelFromScale(0.1)).toBe("low");
+ expect(lodLevelFromScale(0.3)).toBe("medium");
+ expect(lodLevelFromScale(0.8)).toBe("high");
+ const updates = computeLodUpdatesJs(
+ [{ id: "n1", shape: "circularImage", size: 25, _originalShape: "circularImage", _originalSize: 25 }],
+ "low",
+ false
+ );
+ expect(updates[0].shape).toBe("dot");
+ });
+
+ it("falls back to JS when WASM export throws", () => {
+ globalThis.meshchatxVisualiserPathHashes = () => {
+ throw new Error("boom");
+ };
+ globalThis.meshchatxVisualiserBuildPathGraph = () => {
+ throw new Error("boom");
+ };
+ globalThis.meshchatxVisualiserDedupeIcons = () => {
+ throw new Error("boom");
+ };
+ expect(pathHashesWithinHopFilter([{ hash: "aa", hops: 1 }], 4)).toEqual(["aa"]);
+ expect(dedupeIconQueueEntries([])).toEqual([]);
+ expect(buildPathGraph({ path_table: [], announces: {} }).nodes).toEqual([]);
+ });
});

diff --git a/tests/frontend/resourceBreakdown.test.js b/tests/frontend/resourceBreakdown.test.js
new file mode 100644
index 00000000..c6c263eb
--- /dev/null
+++ b/tests/frontend/resourceBreakdown.test.js
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+import { mergeResourceBreakdown, topResourceByCpu, topResourceByRss } from "@/js/resourceBreakdown.js";
+
+describe("resourceBreakdown", () => {
+ it("merges electron private memory as bytes", () => {
+ const rows = mergeResourceBreakdown([{ name: "backend", rss: 10_000_000, cpu_percent: 3 }], { private: 2048 });
+ expect(rows).toHaveLength(2);
+ expect(rows[1]).toEqual({ name: "electron", rss: 2048 * 1024, cpu_percent: null });
+ expect(topResourceByRss(rows)?.name).toBe("backend");
+ expect(topResourceByCpu(rows)?.name).toBe("backend");
+ });
+
+ it("picks top cpu when present", () => {
+ const rows = mergeResourceBreakdown(
+ [
+ { name: "backend", rss: 10, cpu_percent: 1 },
+ { name: "child:bot", rss: 5, cpu_percent: 40 },
+ ],
+ null
+ );
+ expect(topResourceByCpu(rows)?.name).toBe("child:bot");
+ expect(topResourceByRss(rows)?.name).toBe("backend");
+ });
+});

diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js
index 346198bd..598bbe78 100644
--- a/tests/frontend/settingsTabs.test.js
+++ b/tests/frontend/settingsTabs.test.js
@@ -26,6 +26,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
"nomadRenderer",
"crawler",
"appearance",
+ "battery",
"visualiser",
"location",
"language",
@@ -66,6 +67,7 @@ describe("settingsTabs", () => {
it("maps sections to tabs", () => {
expect(settingsTabForSection("appearance")).toBe("general");
+ expect(settingsTabForSection("battery")).toBe("general");
expect(settingsTabForSection("location")).toBe("general");
expect(settingsTabForSection("messages")).toBe("messages");
expect(settingsTabForSection("archiver")).toBe("nomad");

diff --git a/tests/frontend/settingsVisualiserPrefs.test.js b/tests/frontend/settingsVisualiserPrefs.test.js
new file mode 100644
index 00000000..48ec8bbe
--- /dev/null
+++ b/tests/frontend/settingsVisualiserPrefs.test.js
@@ -0,0 +1,45 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import {
+ loadVisualiserDisplayPrefs,
+ persistVisualiserLiveLayout,
+ persistVisualiserAutoReload,
+ persistVisualiserShowDisabled,
+ persistVisualiserShowDiscovered,
+} from "@/js/settings/settingsVisualiserPrefs.js";
+
+describe("settingsVisualiserPrefs", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("defaults live layout on and auto-reload off", () => {
+ expect(loadVisualiserDisplayPrefs()).toEqual({
+ showDisabledInterfaces: false,
+ showDiscoveredInterfaces: false,
+ enablePhysics: true,
+ autoReload: false,
+ });
+ });
+
+ it("persists live layout and auto-reload across loads", () => {
+ persistVisualiserLiveLayout(false);
+ persistVisualiserAutoReload(true);
+ persistVisualiserShowDisabled(true);
+ persistVisualiserShowDiscovered(true);
+ expect(loadVisualiserDisplayPrefs()).toEqual({
+ showDisabledInterfaces: true,
+ showDiscoveredInterfaces: true,
+ enablePhysics: false,
+ autoReload: true,
+ });
+ persistVisualiserLiveLayout(true);
+ expect(loadVisualiserDisplayPrefs().enablePhysics).toBe(true);
+ });
+
+ it("can persist live layout without emitting rebuild events", () => {
+ persistVisualiserLiveLayout(false, { emit: false });
+ expect(loadVisualiserDisplayPrefs().enablePhysics).toBe(false);
+ persistVisualiserAutoReload(true, { emit: false });
+ expect(loadVisualiserDisplayPrefs().autoReload).toBe(true);
+ });
+});

diff --git a/visualiser-wasm/cmd/wasm/main.go b/visualiser-wasm/cmd/wasm/main.go
new file mode 100644
index 00000000..e748e3de
--- /dev/null
+++ b/visualiser-wasm/cmd/wasm/main.go
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: 0BSD
+
+// Command wasm exposes network visualiser hot-path helpers to the browser.
+package main
+
+import (
+ "encoding/json"
+ "syscall/js"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/graph"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/layout"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/lod"
+)
+
+const apiVersion = "1.1.0"
+
+func main() {
+ js.Global().Set("meshchatxVisualiserVersion", apiVersion)
+ js.Global().Set("meshchatxVisualiserPathHashes", js.FuncOf(wrapJSON(pathHashesHandler)))
+ js.Global().Set("meshchatxVisualiserDedupeIcons", js.FuncOf(wrapJSON(dedupeIconsHandler)))
+ js.Global().Set("meshchatxVisualiserBuildPathGraph", js.FuncOf(wrapJSON(buildPathGraphHandler)))
+ js.Global().Set("meshchatxVisualiserBuildFullGraph", js.FuncOf(wrapJSON(buildFullGraphHandler)))
+ js.Global().Set("meshchatxVisualiserLayout", js.FuncOf(wrapJSON(layoutHandler)))
+ js.Global().Set("meshchatxVisualiserLODUpdates", js.FuncOf(wrapJSON(lodUpdatesHandler)))
+ js.Global().Set("meshchatxVisualiserLODLevel", js.FuncOf(lodLevelHandler))
+
+ select {}
+}
+
+type handlerFunc func(args []js.Value) (any, error)
+
+func wrapJSON(fn handlerFunc) func(js.Value, []js.Value) any {
+ return func(_ js.Value, args []js.Value) any {
+ out, err := fn(args)
+ if err != nil {
+ return js.ValueOf(map[string]any{
+ "ok": false,
+ "error": err.Error(),
+ })
+ }
+ buf, err := json.Marshal(out)
+ if err != nil {
+ return js.ValueOf(map[string]any{
+ "ok": false,
+ "error": err.Error(),
+ })
+ }
+ return js.ValueOf(string(buf))
+ }
+}
+
+func readJSONArg(args []js.Value, idx int, dest any) error {
+ if idx >= len(args) {
+ return errMissingArg
+ }
+ raw := args[idx].String()
+ return json.Unmarshal([]byte(raw), dest)
+}
+
+var errMissingArg = errString("missing json argument")
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
+
+func pathHashesHandler(args []js.Value) (any, error) {
+ var pathTable []filter.PathEntry
+ if err := readJSONArg(args, 0, &pathTable); err != nil {
+ return nil, err
+ }
+ var hopMax *float64
+ if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() {
+ if args[1].Type() == js.TypeNumber {
+ v := args[1].Float()
+ hopMax = &v
+ } else if args[1].Type() == js.TypeString {
+ s := args[1].String()
+ if s != "" && s != "null" {
+ var v float64
+ if err := json.Unmarshal([]byte(s), &v); err == nil {
+ hopMax = &v
+ }
+ }
+ }
+ }
+ return filter.PathHashesWithinHopFilter(pathTable, hopMax), nil
+}
+
+func dedupeIconsHandler(args []js.Value) (any, error) {
+ var queue []icon.QueueItem
+ if err := readJSONArg(args, 0, &queue); err != nil {
+ return nil, err
+ }
+ return icon.DedupeQueueEntries(queue), nil
+}
+
+func buildPathGraphHandler(args []js.Value) (any, error) {
+ var req graph.Request
+ if err := readJSONArg(args, 0, &req); err != nil {
+ return nil, err
+ }
+ return graph.BuildPathGraph(req), nil
+}
+
+func buildFullGraphHandler(args []js.Value) (any, error) {
+ var req graph.FullRequest
+ if err := readJSONArg(args, 0, &req); err != nil {
+ return nil, err
+ }
+ return graph.BuildFullGraph(req), nil
+}
+
+func layoutHandler(args []js.Value) (any, error) {
+ var req layout.Request
+ if err := readJSONArg(args, 0, &req); err != nil {
+ return nil, err
+ }
+ return layout.Settle(req), nil
+}
+
+func lodUpdatesHandler(args []js.Value) (any, error) {
+ var payload struct {
+ Nodes []lod.NodeIn `json:"nodes"`
+ LOD string `json:"lod"`
+ DarkMode bool `json:"dark_mode"`
+ }
+ if err := readJSONArg(args, 0, &payload); err != nil {
+ return nil, err
+ }
+ return lod.ComputeUpdates(payload.Nodes, payload.LOD, payload.DarkMode), nil
+}
+
+func lodLevelHandler(_ js.Value, args []js.Value) any {
+ if len(args) == 0 || args[0].Type() != js.TypeNumber {
+ return "high"
+ }
+ return lod.LevelFromScale(args[0].Float())
+}

diff --git a/visualiser-wasm/go.mod b/visualiser-wasm/go.mod
new file mode 100644
index 00000000..fe7beac7
--- /dev/null
+++ b/visualiser-wasm/go.mod
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: 0BSD
+
+module github.com/Quad4-Software/MeshChatX/visualiser-wasm
+
+go 1.22

diff --git a/visualiser-wasm/internal/filter/hop.go b/visualiser-wasm/internal/filter/hop.go
new file mode 100644
index 00000000..a4571e96
--- /dev/null
+++ b/visualiser-wasm/internal/filter/hop.go
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: 0BSD
+
+// Package filter implements hop and search filters for the network visualiser.
+package filter
+
+import "strings"
+
+// PathEntry is a compact path-table row.
+type PathEntry struct {
+ Hash string `json:"hash"`
+ Interface string `json:"interface"`
+ Hops *float64 `json:"hops"`
+}
+
+// PathHashesWithinHopFilter returns unique destination hashes within hopMax.
+// A nil hopMax means no hop ceiling. Rows with nil hops are skipped.
+func PathHashesWithinHopFilter(pathTable []PathEntry, hopMax *float64) []string {
+ if len(pathTable) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(pathTable)/2+1)
+ out := make([]string, 0, len(pathTable)/4+1)
+ for i := range pathTable {
+ e := &pathTable[i]
+ if e.Hops == nil || e.Hash == "" {
+ continue
+ }
+ if hopMax != nil && *e.Hops > *hopMax {
+ continue
+ }
+ if _, ok := seen[e.Hash]; ok {
+ continue
+ }
+ seen[e.Hash] = struct{}{}
+ out = append(out, e.Hash)
+ }
+ return out
+}
+
+// MatchesSearch reports whether text matches queryLower (already lowercased).
+// An empty query matches everything.
+// ASCII-only text avoids allocating a lowered copy.
+func MatchesSearch(queryLower, text string) bool {
+ if queryLower == "" {
+ return true
+ }
+ if text == "" {
+ return false
+ }
+ if isASCII(text) {
+ return containsFoldASCII(text, queryLower)
+ }
+ return strings.Contains(strings.ToLower(text), queryLower)
+}
+
+func isASCII(s string) bool {
+ for i := 0; i < len(s); i++ {
+ if s[i] >= 0x80 {
+ return false
+ }
+ }
+ return true
+}
+
+func containsFoldASCII(haystack, needleLower string) bool {
+ n := len(needleLower)
+ if n == 0 {
+ return true
+ }
+ if n > len(haystack) {
+ return false
+ }
+ for i := 0; i+n <= len(haystack); i++ {
+ ok := true
+ for j := 0; j < n; j++ {
+ c := haystack[i+j]
+ if c >= 'A' && c <= 'Z' {
+ c += 'a' - 'A'
+ }
+ if c != needleLower[j] {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ return true
+ }
+ }
+ return false
+}

diff --git a/visualiser-wasm/internal/filter/hop_test.go b/visualiser-wasm/internal/filter/hop_test.go
new file mode 100644
index 00000000..9b1be0c1
--- /dev/null
+++ b/visualiser-wasm/internal/filter/hop_test.go
@@ -0,0 +1,148 @@
+// SPDX-License-Identifier: 0BSD
+
+package filter_test
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
+)
+
+func ptr(v float64) *float64 { return &v }
+
+func TestPathHashesWithinHopFilter(t *testing.T) {
+ table := []filter.PathEntry{
+ {Hash: "aa", Hops: ptr(1)},
+ {Hash: "bb", Hops: ptr(4)},
+ {Hash: "cc", Hops: ptr(5)},
+ {Hash: "dd", Hops: nil},
+ {Hash: "aa", Hops: ptr(1)},
+ }
+ got := filter.PathHashesWithinHopFilter(table, ptr(4))
+ if len(got) != 2 || got[0] != "aa" || got[1] != "bb" {
+ t.Fatalf("unexpected: %#v", got)
+ }
+ gotAll := filter.PathHashesWithinHopFilter(table, nil)
+ if len(gotAll) != 3 {
+ t.Fatalf("expected 3 unique hashes, got %#v", gotAll)
+ }
+ if filter.PathHashesWithinHopFilter(nil, ptr(1)) != nil {
+ t.Fatal("empty should return nil")
+ }
+}
+
+func TestMatchesSearch(t *testing.T) {
+ if !filter.MatchesSearch("", "anything") {
+ t.Fatal("empty query should match")
+ }
+ if !filter.MatchesSearch("foo", "FooBar") {
+ t.Fatal("expected case-insensitive match")
+ }
+ if filter.MatchesSearch("zzz", "abc") {
+ t.Fatal("expected miss")
+ }
+ if !filter.MatchesSearch("café", "Café Latte") {
+ t.Fatal("expected unicode fold match")
+ }
+}
+
+func TestFilterNoGoroutineLeak(t *testing.T) {
+ defer leaktest.Check(t)()
+ table := make([]filter.PathEntry, 200)
+ for i := range table {
+ h := float64(i % 8)
+ table[i] = filter.PathEntry{Hash: "h" + string(rune('a'+i%26)), Hops: &h}
+ }
+ for i := 0; i < 200; i++ {
+ _ = filter.PathHashesWithinHopFilter(table, ptr(4))
+ _ = filter.MatchesSearch("ab", "Alphabet")
+ }
+}
+
+func TestFilterRace(t *testing.T) {
+ table := make([]filter.PathEntry, 500)
+ for i := range table {
+ h := float64(i%6 + 1)
+ table[i] = filter.PathEntry{Hash: "hash-" + string(rune('0'+i%10)), Interface: "eth0", Hops: &h}
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ _ = filter.PathHashesWithinHopFilter(table, ptr(3))
+ _ = filter.MatchesSearch("hash", "HASH-1")
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func FuzzPathHashesWithinHopFilter(f *testing.F) {
+ f.Add("aa", float64(1), float64(4))
+ f.Add("", float64(0), float64(0))
+ f.Fuzz(func(t *testing.T, hash string, hops, hopMax float64) {
+ table := []filter.PathEntry{{Hash: hash, Hops: &hops}}
+ out := filter.PathHashesWithinHopFilter(table, &hopMax)
+ if hops > hopMax {
+ if len(out) != 0 {
+ t.Fatalf("expected empty for hops>%v", hopMax)
+ }
+ return
+ }
+ if hash == "" {
+ if len(out) != 0 {
+ t.Fatal("empty hash should be skipped")
+ }
+ return
+ }
+ if len(out) != 1 || out[0] != hash {
+ t.Fatalf("unexpected %#v", out)
+ }
+ })
+}
+
+func FuzzMatchesSearch(f *testing.F) {
+ f.Add("foo", "FooBar")
+ f.Add("", "x")
+ f.Add("z", "")
+ f.Fuzz(func(t *testing.T, q, text string) {
+ _ = filter.MatchesSearch(q, text)
+ if q == "" && !filter.MatchesSearch(q, text) {
+ t.Fatal("empty query must match")
+ }
+ })
+}
+
+func BenchmarkPathHashesWithinHopFilter(b *testing.B) {
+ table := make([]filter.PathEntry, 2000)
+ for i := range table {
+ h := float64(i%8 + 1)
+ table[i] = filter.PathEntry{Hash: "h" + string(rune('a'+i%26)) + string(rune('0'+i%10)), Hops: &h}
+ }
+ max := 4.0
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = filter.PathHashesWithinHopFilter(table, &max)
+ }
+}
+
+func BenchmarkMatchesSearchASCII(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = filter.MatchesSearch("mesh", "ReticulumMeshNode")
+ }
+}
+
+func TestMatchesSearchASCIIZeroAllocs(t *testing.T) {
+ allocs := testing.AllocsPerRun(1000, func() {
+ _ = filter.MatchesSearch("mesh", "ReticulumMeshNode")
+ })
+ if allocs != 0 {
+ t.Fatalf("expected 0 allocs for ASCII search, got %v", allocs)
+ }
+}

diff --git a/visualiser-wasm/internal/graph/build.go b/visualiser-wasm/internal/graph/build.go
new file mode 100644
index 00000000..554d7bf2
--- /dev/null
+++ b/visualiser-wasm/internal/graph/build.go
@@ -0,0 +1,407 @@
+// SPDX-License-Identifier: 0BSD
+
+package graph
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
+)
+
+// XY is a 2D position.
+type XY struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+// UserIcon is an lxmf custom icon descriptor.
+type UserIcon struct {
+ IconName string `json:"icon_name"`
+ ForegroundColour string `json:"foreground_colour"`
+ BackgroundColour string `json:"background_colour"`
+}
+
+// Announce is the announce fields needed for graph build.
+type Announce struct {
+ DestinationHash string `json:"destination_hash"`
+ Aspect string `json:"aspect"`
+ DisplayName string `json:"display_name"`
+ CustomDisplayName string `json:"custom_display_name"`
+ IdentityHash string `json:"identity_hash"`
+ LastSeen string `json:"last_seen"`
+}
+
+// Conversation carries optional lxmf user icon data.
+type Conversation struct {
+ LxmfUserIcon *UserIcon `json:"lxmf_user_icon"`
+}
+
+// Request is the buildPathGraph input payload.
+type Request struct {
+ PathTable []filter.PathEntry `json:"path_table"`
+ Announces map[string]Announce `json:"announces"`
+ Conversations map[string]Conversation `json:"conversations"`
+ IconCache map[string]string `json:"icon_cache"`
+ Positions map[string]XY `json:"positions"`
+ HopMax *float64 `json:"hop_max"`
+ Search string `json:"search"`
+ DarkMode bool `json:"dark_mode"`
+ LOD string `json:"lod"`
+ Aspects []string `json:"aspects"`
+ QueueIcons bool `json:"queue_icons"`
+ IconGeneration int `json:"icon_generation"`
+}
+
+// NodeOut is one announce node for vis-network.
+type NodeOut struct {
+ ID string `json:"id"`
+ Group string `json:"group"`
+ Size float64 `json:"size"`
+ OriginalSize float64 `json:"_originalSize"`
+ Shape string `json:"shape"`
+ OriginalShape string `json:"_originalShape"`
+ Image string `json:"image,omitempty"`
+ Label string `json:"label"`
+ Title string `json:"title"`
+ Font map[string]any `json:"font"`
+ Color map[string]any `json:"color"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ ParentInterface string `json:"_parentInterface,omitempty"`
+}
+
+// EdgeOut is one path edge for vis-network.
+type EdgeOut struct {
+ ID string `json:"id"`
+ From string `json:"from"`
+ To string `json:"to"`
+ Color map[string]any `json:"color"`
+ Width float64 `json:"width"`
+ Hidden bool `json:"hidden"`
+}
+
+// Result is the buildPathGraph output payload.
+type Result struct {
+ Nodes []NodeOut `json:"nodes"`
+ Edges []EdgeOut `json:"edges"`
+ IconQueue []icon.QueueItem `json:"icon_queue"`
+ ProcessedNodeIDs []string `json:"processed_node_ids"`
+ ProcessedEdgeIDs []string `json:"processed_edge_ids"`
+}
+
+var defaultAspects = []string{"lxmf.delivery", "nomadnetwork.node"}
+
+// Shared immutable style maps reused across nodes and edges to cut allocs.
+var (
+ fontHighLight = map[string]any{"color": "#ffffff", "size": 11.0}
+ fontHighDark = map[string]any{"color": "#000000", "size": 11.0}
+ fontHidden = map[string]any{"size": 0.0}
+
+ colorLxmfDirectLight = nodeColor("#10b981", "#ecfdf5")
+ colorLxmfDirectDark = nodeColor("#10b981", "#064e3b")
+ colorLxmfMultiLight = nodeColor("#3b82f6", "#eff6ff")
+ colorLxmfMultiDark = nodeColor("#3b82f6", "#1e40af")
+ colorNomadDirectLight = nodeColor("#10b981", "#ecfdf5")
+ colorNomadDirectDark = nodeColor("#10b981", "#064e3b")
+ colorNomadMultiLight = nodeColor("#8b5cf6", "#f5f3ff")
+ colorNomadMultiDark = nodeColor("#8b5cf6", "#4c1d95")
+
+ edgeDirectLight = map[string]any{"color": "#10b981", "opacity": 1.0}
+ edgeDirectDark = map[string]any{"color": "#34d399", "opacity": 1.0}
+ edgeMultiLight = map[string]any{"color": "#3b82f6", "opacity": 0.5}
+ edgeMultiDark = map[string]any{"color": "#60a5fa", "opacity": 0.5}
+
+ imgUser1Hop = "/assets/images/network-visualiser/user_1hop.png"
+ imgUser = "/assets/images/network-visualiser/user.png"
+ imgServer1Hop = "/assets/images/network-visualiser/server_1hop.png"
+ imgServer = "/assets/images/network-visualiser/server.png"
+)
+
+// BuildPathGraph constructs announce nodes and edges from the path table.
+func BuildPathGraph(req Request) Result {
+ aspects := req.Aspects
+ if len(aspects) == 0 {
+ aspects = defaultAspects
+ }
+ aspectSet := make(map[string]struct{}, len(aspects))
+ for _, a := range aspects {
+ aspectSet[a] = struct{}{}
+ }
+
+ searchLower := strings.ToLower(req.Search)
+ fontHigh := fontHighDark
+ if req.DarkMode {
+ fontHigh = fontHighLight
+ }
+
+ est := len(req.PathTable)
+ if est > 4096 {
+ est = 4096
+ }
+ nodes := make([]NodeOut, 0, est/2+1)
+ edges := make([]EdgeOut, 0, est/2+1)
+ iconQueue := make([]icon.QueueItem, 0, 32)
+ nodeIDs := make([]string, 0, est/2+1)
+ edgeIDs := make([]string, 0, est/2+1)
+
+ pos := req.Positions
+ if pos == nil {
+ pos = map[string]XY{}
+ }
+ cache := req.IconCache
+ if cache == nil {
+ cache = map[string]string{}
+ }
+ announces := req.Announces
+ if announces == nil {
+ announces = map[string]Announce{}
+ }
+ conversations := req.Conversations
+ if conversations == nil {
+ conversations = map[string]Conversation{}
+ }
+
+ for i := range req.PathTable {
+ entry := &req.PathTable[i]
+ if entry.Hops == nil || entry.Hash == "" {
+ continue
+ }
+ if req.HopMax != nil && *entry.Hops > *req.HopMax {
+ continue
+ }
+ announce, ok := announces[entry.Hash]
+ if !ok {
+ continue
+ }
+ if _, allow := aspectSet[announce.Aspect]; !allow {
+ continue
+ }
+
+ displayName := announce.CustomDisplayName
+ if displayName == "" {
+ displayName = announce.DisplayName
+ }
+ if !filter.MatchesSearch(searchLower, displayName) &&
+ !filter.MatchesSearch(searchLower, announce.DestinationHash) &&
+ !filter.MatchesSearch(searchLower, announce.IdentityHash) {
+ continue
+ }
+
+ x, y := resolvePosition(entry.Hash, entry.Interface, pos)
+ edgeID := entry.Interface + "~" + entry.Hash
+ direct := *entry.Hops == 1
+
+ node := NodeOut{
+ ID: entry.Hash,
+ Group: "announce",
+ Size: 25,
+ OriginalSize: 25,
+ Label: displayName,
+ Title: buildTitle(displayName, announce.Aspect, *entry.Hops, entry.Interface, announce.LastSeen),
+ Font: fontHigh,
+ X: x,
+ Y: y,
+ ParentInterface: entry.Interface,
+ }
+
+ conv := conversations[announce.DestinationHash]
+ switch announce.Aspect {
+ case "lxmf.delivery":
+ applyLxmfNode(&node, &conv, direct, req.DarkMode, req.QueueIcons, req.IconGeneration, cache, &iconQueue)
+ case "nomadnetwork.node":
+ applyNomadNode(&node, direct, req.DarkMode)
+ }
+
+ applyLOD(&node, req.LOD, fontHigh)
+ nodes = append(nodes, node)
+ nodeIDs = append(nodeIDs, node.ID)
+
+ edges = append(edges, EdgeOut{
+ ID: edgeID,
+ From: entry.Interface,
+ To: entry.Hash,
+ Color: edgeColor(direct, req.DarkMode),
+ Width: edgeWidth(direct),
+ Hidden: false,
+ })
+ edgeIDs = append(edgeIDs, edgeID)
+ }
+
+ return Result{
+ Nodes: nodes,
+ Edges: edges,
+ IconQueue: iconQueue,
+ ProcessedNodeIDs: nodeIDs,
+ ProcessedEdgeIDs: edgeIDs,
+ }
+}
+
+func resolvePosition(hash, iface string, pos map[string]XY) (float64, float64) {
+ if prev, ok := pos[hash]; ok {
+ return prev.X, prev.Y
+ }
+ if ip, ok := pos[iface]; ok {
+ x, y := hashpos.Around(hash, ip.X, ip.Y, 150, 150)
+ pos[hash] = XY{X: x, Y: y}
+ return x, y
+ }
+ x, y := hashpos.XY(hash, 600, 200)
+ pos[hash] = XY{X: x, Y: y}
+ return x, y
+}
+
+func buildTitle(displayName, aspect string, hops float64, via, lastSeen string) string {
+ var b strings.Builder
+ b.Grow(96 + len(displayName) + len(aspect) + len(via) + len(lastSeen))
+ b.WriteString(displayName)
+ b.WriteByte('\n')
+ b.WriteString("Aspect: ")
+ b.WriteString(aspect)
+ b.WriteByte('\n')
+ b.WriteString("Hops: ")
+ b.WriteString(trimFloat(hops))
+ b.WriteByte('\n')
+ b.WriteString("Via: ")
+ b.WriteString(via)
+ b.WriteByte('\n')
+ b.WriteString("Last Seen: ")
+ b.WriteString(lastSeen)
+ return b.String()
+}
+
+func trimFloat(v float64) string {
+ return strconv.FormatFloat(v, 'f', -1, 64)
+}
+
+func applyLxmfNode(
+ node *NodeOut,
+ conv *Conversation,
+ direct, darkMode, queueIcons bool,
+ generation int,
+ cache map[string]string,
+ iconQueue *[]icon.QueueItem,
+) {
+ node.Shape = "circularImage"
+ node.OriginalShape = "circularImage"
+ if conv != nil && conv.LxmfUserIcon != nil {
+ ic := conv.LxmfUserIcon
+ cacheKey := ic.IconName + "-" + ic.ForegroundColour + "-" + ic.BackgroundColour + "-64"
+ if url := cache[cacheKey]; url != "" {
+ node.Image = url
+ } else {
+ if direct {
+ node.Image = imgUser1Hop
+ } else {
+ node.Image = imgUser
+ }
+ if queueIcons {
+ *iconQueue = append(*iconQueue, icon.QueueItem{
+ NodeID: node.ID,
+ CacheKey: cacheKey,
+ IconName: ic.IconName,
+ FG: ic.ForegroundColour,
+ BG: ic.BackgroundColour,
+ Size: 64,
+ Generation: generation,
+ })
+ }
+ }
+ node.Size = 30
+ node.OriginalSize = 30
+ } else if direct {
+ node.Image = imgUser1Hop
+ } else {
+ node.Image = imgUser
+ }
+ if direct {
+ if darkMode {
+ node.Color = colorLxmfDirectDark
+ } else {
+ node.Color = colorLxmfDirectLight
+ }
+ } else if darkMode {
+ node.Color = colorLxmfMultiDark
+ } else {
+ node.Color = colorLxmfMultiLight
+ }
+}
+
+func applyNomadNode(node *NodeOut, direct, darkMode bool) {
+ node.Shape = "circularImage"
+ node.OriginalShape = "circularImage"
+ if direct {
+ node.Image = imgServer1Hop
+ } else {
+ node.Image = imgServer
+ }
+ if direct {
+ if darkMode {
+ node.Color = colorNomadDirectDark
+ } else {
+ node.Color = colorNomadDirectLight
+ }
+ } else if darkMode {
+ node.Color = colorNomadMultiDark
+ } else {
+ node.Color = colorNomadMultiLight
+ }
+}
+
+func applyLOD(node *NodeOut, level string, fontHigh map[string]any) {
+ switch level {
+ case "low":
+ node.Shape = "dot"
+ if node.ID == "me" {
+ node.Size = 15
+ } else {
+ node.Size = 10
+ }
+ node.Font = fontHidden
+ case "medium":
+ node.Shape = node.OriginalShape
+ node.Size = node.OriginalSize
+ node.Font = fontHidden
+ default:
+ node.Shape = node.OriginalShape
+ node.Size = node.OriginalSize
+ node.Font = fontHigh
+ }
+}
+
+func edgeColor(direct, darkMode bool) map[string]any {
+ if direct {
+ if darkMode {
+ return edgeDirectDark
+ }
+ return edgeDirectLight
+ }
+ if darkMode {
+ return edgeMultiDark
+ }
+ return edgeMultiLight
+}
+
+func edgeWidth(direct bool) float64 {
+ if direct {
+ return 2.5
+ }
+ return 1
+}
+
+func nodeColor(border, background string) map[string]any {
+ return map[string]any{
+ "border": border,
+ "background": background,
+ "highlight": map[string]any{
+ "border": border,
+ "background": background,
+ },
+ "hover": map[string]any{
+ "border": border,
+ "background": background,
+ },
+ }
+}

diff --git a/visualiser-wasm/internal/graph/build_test.go b/visualiser-wasm/internal/graph/build_test.go
new file mode 100644
index 00000000..04733bd7
--- /dev/null
+++ b/visualiser-wasm/internal/graph/build_test.go
@@ -0,0 +1,196 @@
+// SPDX-License-Identifier: 0BSD
+
+package graph_test
+
+import (
+ "fmt"
+ "runtime"
+ "sync"
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/graph"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
+)
+
+func hops(v float64) *float64 { return &v }
+
+func sampleRequest(n int) graph.Request {
+ path := make([]filter.PathEntry, n)
+ ann := make(map[string]graph.Announce, n)
+ for i := 0; i < n; i++ {
+ hash := fmt.Sprintf("%032x", i)
+ h := float64(i%5 + 1)
+ path[i] = filter.PathEntry{Hash: hash, Interface: "eth0", Hops: &h}
+ aspect := "lxmf.delivery"
+ if i%3 == 0 {
+ aspect = "nomadnetwork.node"
+ }
+ ann[hash] = graph.Announce{
+ DestinationHash: hash,
+ Aspect: aspect,
+ DisplayName: "Node" + hash[:4],
+ LastSeen: "now",
+ }
+ }
+ return graph.Request{
+ PathTable: path,
+ Announces: ann,
+ Positions: map[string]graph.XY{"eth0": {X: 100, Y: 200}},
+ HopMax: hops(4),
+ DarkMode: true,
+ LOD: "high",
+ }
+}
+
+func TestBuildPathGraphFiltersAndBuilds(t *testing.T) {
+ req := graph.Request{
+ PathTable: []filter.PathEntry{
+ {Hash: "aa", Interface: "eth0", Hops: hops(1)},
+ {Hash: "bb", Interface: "eth0", Hops: hops(3)},
+ {Hash: "cc", Interface: "eth0", Hops: hops(9)},
+ {Hash: "dd", Interface: "eth0", Hops: nil},
+ },
+ Announces: map[string]graph.Announce{
+ "aa": {DestinationHash: "aa", Aspect: "lxmf.delivery", DisplayName: "Alice", LastSeen: "now"},
+ "bb": {DestinationHash: "bb", Aspect: "nomadnetwork.node", DisplayName: "Bob", LastSeen: "now"},
+ "cc": {DestinationHash: "cc", Aspect: "lxmf.delivery", DisplayName: "Far", LastSeen: "now"},
+ },
+ Positions: map[string]graph.XY{
+ "eth0": {X: 100, Y: 200},
+ },
+ HopMax: hops(4),
+ DarkMode: true,
+ LOD: "high",
+ }
+ res := graph.BuildPathGraph(req)
+ if len(res.Nodes) != 2 || len(res.Edges) != 2 {
+ t.Fatalf("expected 2 nodes/edges, got %d/%d", len(res.Nodes), len(res.Edges))
+ }
+ if res.Nodes[0].Image != "/assets/images/network-visualiser/user_1hop.png" {
+ t.Fatalf("unexpected lxmf image: %s", res.Nodes[0].Image)
+ }
+ if res.Edges[0].Width != 2.5 {
+ t.Fatalf("direct edge width: %v", res.Edges[0].Width)
+ }
+}
+
+func TestBuildPathGraphSearchAndIcons(t *testing.T) {
+ req := sampleRequest(20)
+ req.Search = "node0000"
+ req.QueueIcons = true
+ req.Conversations = map[string]graph.Conversation{
+ req.PathTable[0].Hash: {
+ LxmfUserIcon: &graph.UserIcon{IconName: "account", ForegroundColour: "#000", BackgroundColour: "#fff"},
+ },
+ }
+ res := graph.BuildPathGraph(req)
+ if len(res.Nodes) == 0 {
+ t.Fatal("expected search hits")
+ }
+}
+
+func TestGraphNoGoroutineLeak(t *testing.T) {
+ defer leaktest.Check(t)()
+ req := sampleRequest(200)
+ for i := 0; i < 50; i++ {
+ _ = graph.BuildPathGraph(req)
+ }
+}
+
+func TestGraphRace(t *testing.T) {
+ req := sampleRequest(300)
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ local := req
+ local.Positions = map[string]graph.XY{"eth0": {X: 1, Y: 2}}
+ for j := 0; j < 20; j++ {
+ _ = graph.BuildPathGraph(local)
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func TestGraphHeapStableAcrossRuns(t *testing.T) {
+ req := sampleRequest(500)
+ runtime.GC()
+ var start, end runtime.MemStats
+ runtime.ReadMemStats(&start)
+ for i := 0; i < 30; i++ {
+ _ = graph.BuildPathGraph(req)
+ }
+ runtime.GC()
+ runtime.ReadMemStats(&end)
+ // Allow generous headroom for allocator noise while catching runaway retention.
+ const maxGrowth = 32 << 20
+ if end.HeapAlloc > start.HeapAlloc+maxGrowth {
+ t.Fatalf("heap grew too much: start=%d end=%d", start.HeapAlloc, end.HeapAlloc)
+ }
+}
+
+func FuzzBuildPathGraph(f *testing.F) {
+ f.Add("aa", float64(1), "lxmf.delivery", "Alice", float64(4), "ali")
+ f.Add("bb", float64(9), "nomadnetwork.node", "Bob", float64(2), "")
+ f.Fuzz(func(t *testing.T, hash string, hopsVal float64, aspect, name string, hopMax float64, search string) {
+ if hash == "" {
+ return
+ }
+ req := graph.Request{
+ PathTable: []filter.PathEntry{{Hash: hash, Interface: "eth0", Hops: &hopsVal}},
+ Announces: map[string]graph.Announce{
+ hash: {DestinationHash: hash, Aspect: aspect, DisplayName: name, LastSeen: "t"},
+ },
+ HopMax: &hopMax,
+ Search: search,
+ DarkMode: true,
+ LOD: "medium",
+ }
+ res := graph.BuildPathGraph(req)
+ if len(res.Nodes) != len(res.Edges) {
+ t.Fatalf("nodes/edges mismatch %d/%d", len(res.Nodes), len(res.Edges))
+ }
+ if len(res.Nodes) != len(res.ProcessedNodeIDs) {
+ t.Fatalf("node id mismatch")
+ }
+ })
+}
+
+func BenchmarkBuildPathGraph1000(b *testing.B) {
+ req := sampleRequest(1000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ local := req
+ local.Positions = map[string]graph.XY{"eth0": {X: 100, Y: 200}}
+ _ = graph.BuildPathGraph(local)
+ }
+}
+
+func BenchmarkBuildPathGraph2000(b *testing.B) {
+ req := sampleRequest(2000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ local := req
+ local.Positions = map[string]graph.XY{"eth0": {X: 100, Y: 200}}
+ _ = graph.BuildPathGraph(local)
+ }
+}
+
+func TestBuildPathGraphAllocBudget(t *testing.T) {
+ req := sampleRequest(200)
+ allocs := testing.AllocsPerRun(20, func() {
+ local := req
+ local.Positions = map[string]graph.XY{"eth0": {X: 100, Y: 200}}
+ _ = graph.BuildPathGraph(local)
+ })
+ // Graph build must allocate output slices and titles, but stay bounded.
+ const maxAllocs = 5000
+ if allocs > maxAllocs {
+ t.Fatalf("alloc budget exceeded: %v > %d", allocs, maxAllocs)
+ }
+}

diff --git a/visualiser-wasm/internal/graph/full.go b/visualiser-wasm/internal/graph/full.go
new file mode 100644
index 00000000..9049e8d1
--- /dev/null
+++ b/visualiser-wasm/internal/graph/full.go
@@ -0,0 +1,369 @@
+// SPDX-License-Identifier: 0BSD
+
+package graph
+
+import (
+ "math"
+ "strings"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
+)
+
+// InterfaceIn is a preformatted interface row from the JS side.
+type InterfaceIn struct {
+ Name string `json:"name"`
+ Label string `json:"label"`
+ Title string `json:"title"`
+ Online bool `json:"online"`
+}
+
+// DiscoveredIn is a preformatted discovered-interface row.
+type DiscoveredIn struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Title string `json:"title"`
+ Connected bool `json:"connected"`
+ Hops *float64 `json:"hops"`
+}
+
+// FullRequest builds the entire visualiser graph in one WASM pass.
+type FullRequest struct {
+ MeLabel string `json:"me_label"`
+ MeTitle string `json:"me_title"`
+ MeImage string `json:"me_image"`
+ IdentityHash string `json:"identity_hash"`
+ Interfaces []InterfaceIn `json:"interfaces"`
+ PathOnlyInterfaces []InterfaceIn `json:"path_only_interfaces"`
+ Discovered []DiscoveredIn `json:"discovered"`
+ PathTable []filter.PathEntry `json:"path_table"`
+ Announces map[string]Announce `json:"announces"`
+ Conversations map[string]Conversation `json:"conversations"`
+ IconCache map[string]string `json:"icon_cache"`
+ Positions map[string]XY `json:"positions"`
+ HopMax *float64 `json:"hop_max"`
+ Search string `json:"search"`
+ DarkMode bool `json:"dark_mode"`
+ LOD string `json:"lod"`
+ Aspects []string `json:"aspects"`
+ QueueIcons bool `json:"queue_icons"`
+ IconGeneration int `json:"icon_generation"`
+ ShowDiscovered bool `json:"show_discovered"`
+}
+
+// FullResult is nodes/edges for the whole mesh plus layout seeds.
+type FullResult struct {
+ Nodes []NodeOut `json:"nodes"`
+ Edges []EdgeOut `json:"edges"`
+ IconQueue []icon.QueueItem `json:"icon_queue"`
+ ProcessedNodeIDs []string `json:"processed_node_ids"`
+ ProcessedEdgeIDs []string `json:"processed_edge_ids"`
+ LayoutNodes []LayoutBody `json:"layout_nodes"`
+ LayoutEdges []LayoutSpring `json:"layout_edges"`
+}
+
+// LayoutBody is a compact body for the WASM force settle.
+type LayoutBody struct {
+ ID string `json:"id"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Mass float64 `json:"mass"`
+ Fixed bool `json:"fixed"`
+}
+
+// LayoutSpring is a compact spring for the WASM force settle.
+type LayoutSpring struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Length float64 `json:"length"`
+}
+
+// BuildFullGraph constructs me + interfaces + discovered + announce nodes/edges.
+func BuildFullGraph(req FullRequest) FullResult {
+ searchLower := strings.ToLower(req.Search)
+ fontColor := "#000000"
+ if req.DarkMode {
+ fontColor = "#ffffff"
+ }
+ pos := req.Positions
+ if pos == nil {
+ pos = map[string]XY{}
+ }
+
+ est := len(req.PathTable) + len(req.Interfaces) + len(req.PathOnlyInterfaces) + len(req.Discovered) + 1
+ nodes := make([]NodeOut, 0, est)
+ edges := make([]EdgeOut, 0, est)
+ iconQueue := make([]icon.QueueItem, 0, 32)
+ nodeIDs := make([]string, 0, est)
+ edgeIDs := make([]string, 0, est)
+ layoutNodes := make([]LayoutBody, 0, est)
+ layoutEdges := make([]LayoutSpring, 0, est)
+ seen := make(map[string]struct{}, est)
+
+ addNode := func(n NodeOut, mass float64, fixed bool) {
+ if _, ok := seen[n.ID]; ok {
+ return
+ }
+ seen[n.ID] = struct{}{}
+ nodes = append(nodes, n)
+ nodeIDs = append(nodeIDs, n.ID)
+ layoutNodes = append(layoutNodes, LayoutBody{ID: n.ID, X: n.X, Y: n.Y, Mass: mass, Fixed: fixed})
+ }
+ addEdge := func(e EdgeOut, length float64) {
+ edges = append(edges, e)
+ edgeIDs = append(edgeIDs, e.ID)
+ layoutEdges = append(layoutEdges, LayoutSpring{From: e.From, To: e.To, Length: length})
+ }
+
+ meLabel := req.MeLabel
+ if meLabel == "" {
+ meLabel = "Local Node"
+ }
+ if filter.MatchesSearch(searchLower, meLabel) || filter.MatchesSearch(searchLower, req.IdentityHash) {
+ mp := resolveOr(pos, "me", 0, 0)
+ font := map[string]any{"color": fontColor, "size": 16.0, "bold": true}
+ me := NodeOut{
+ ID: "me",
+ Group: "me",
+ Size: 50,
+ OriginalSize: 50,
+ Shape: "circularImage",
+ OriginalShape: "circularImage",
+ Image: req.MeImage,
+ Label: meLabel,
+ Title: req.MeTitle,
+ Font: font,
+ Color: colorBlue(req.DarkMode),
+ X: mp.X,
+ Y: mp.Y,
+ }
+ applyLOD(&me, req.LOD, fontHighFor(req.DarkMode))
+ addNode(me, 4, true)
+ }
+
+ radius := 400.0
+ ifaceN := len(req.Interfaces)
+ for j, entry := range req.Interfaces {
+ if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
+ continue
+ }
+ angle := 0.0
+ if ifaceN > 0 {
+ angle = (float64(j) / float64(ifaceN)) * 2 * math.Pi
+ }
+ init := XY{X: math.Cos(angle) * radius, Y: math.Sin(angle) * radius}
+ p := resolveOr(pos, entry.Name, init.X, init.Y)
+ node := NodeOut{
+ ID: entry.Name,
+ Group: "interface",
+ Label: entry.Label,
+ Title: entry.Title,
+ Size: 35,
+ OriginalSize: 35,
+ Shape: "circularImage",
+ OriginalShape: "circularImage",
+ Image: ifaceImage(entry.Online),
+ Color: ifaceColor(entry.Online, req.DarkMode),
+ Font: map[string]any{"color": fontColor, "size": 12.0, "bold": true},
+ X: p.X,
+ Y: p.Y,
+ }
+ applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
+ addNode(node, 2.5, false)
+ if _, ok := seen["me"]; ok {
+ eid := "me~" + entry.Name
+ col := edgeDirect(req.DarkMode)
+ if !entry.Online {
+ col = edgeOffline(req.DarkMode)
+ }
+ addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: col, Width: 3, Hidden: false}, 200)
+ }
+ }
+
+ pathIfaceN := len(req.PathOnlyInterfaces)
+ for j, entry := range req.PathOnlyInterfaces {
+ if _, ok := seen[entry.Name]; ok {
+ continue
+ }
+ if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
+ continue
+ }
+ angle := 0.0
+ if pathIfaceN > 0 {
+ angle = (float64(j) / float64(pathIfaceN)) * 2 * math.Pi
+ }
+ init := XY{X: math.Cos(angle) * radius, Y: math.Sin(angle) * radius}
+ p := resolveOr(pos, entry.Name, init.X, init.Y)
+ node := NodeOut{
+ ID: entry.Name,
+ Group: "interface",
+ Label: entry.Label,
+ Title: entry.Title,
+ Size: 35,
+ OriginalSize: 35,
+ Shape: "circularImage",
+ OriginalShape: "circularImage",
+ Image: "/assets/images/network-visualiser/interface_connected.png",
+ Color: ifaceColor(true, req.DarkMode),
+ Font: map[string]any{"color": fontColor, "size": 12.0, "bold": true},
+ X: p.X,
+ Y: p.Y,
+ }
+ applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
+ addNode(node, 2.5, false)
+ if _, ok := seen["me"]; ok {
+ eid := "me~" + entry.Name
+ addEdge(EdgeOut{ID: eid, From: "me", To: entry.Name, Color: edgeDirect(req.DarkMode), Width: 3, Hidden: false}, 200)
+ }
+ }
+
+ if req.ShowDiscovered {
+ for _, disc := range req.Discovered {
+ if req.HopMax != nil && disc.Hops != nil && *disc.Hops > *req.HopMax {
+ continue
+ }
+ if !filter.MatchesSearch(searchLower, disc.Label) {
+ continue
+ }
+ x, y := hashpos.XY(disc.ID, 800, 200)
+ p := resolveOr(pos, disc.ID, x, y)
+ node := NodeOut{
+ ID: disc.ID,
+ Group: "discovered",
+ Label: disc.Label,
+ Title: disc.Title,
+ Size: 25,
+ OriginalSize: 25,
+ Shape: "circularImage",
+ OriginalShape: "circularImage",
+ Image: ifaceImage(disc.Connected),
+ Color: discoveredColor(disc.Connected, req.DarkMode),
+ Font: map[string]any{"color": fontColor, "size": 10.0},
+ X: p.X,
+ Y: p.Y,
+ }
+ applyLOD(&node, req.LOD, fontHighFor(req.DarkMode))
+ addNode(node, 1.2, false)
+ if _, ok := seen["me"]; ok {
+ eid := "me~" + disc.ID
+ col := map[string]any{"color": "#06b6d4", "opacity": 0.35}
+ if req.DarkMode {
+ col["color"] = "#155e75"
+ }
+ addEdge(EdgeOut{ID: eid, From: "me", To: disc.ID, Color: col, Width: 1, Hidden: false}, 320)
+ }
+ }
+ }
+
+ pathRes := BuildPathGraph(Request{
+ PathTable: req.PathTable,
+ Announces: req.Announces,
+ Conversations: req.Conversations,
+ IconCache: req.IconCache,
+ Positions: pos,
+ HopMax: req.HopMax,
+ Search: req.Search,
+ DarkMode: req.DarkMode,
+ LOD: req.LOD,
+ Aspects: req.Aspects,
+ QueueIcons: req.QueueIcons,
+ IconGeneration: req.IconGeneration,
+ })
+ for _, n := range pathRes.Nodes {
+ addNode(n, 1, false)
+ }
+ for _, e := range pathRes.Edges {
+ length := 180.0
+ if e.Width >= 2 {
+ length = 150
+ }
+ addEdge(e, length)
+ }
+ iconQueue = append(iconQueue, pathRes.IconQueue...)
+
+ return FullResult{
+ Nodes: nodes,
+ Edges: edges,
+ IconQueue: iconQueue,
+ ProcessedNodeIDs: nodeIDs,
+ ProcessedEdgeIDs: edgeIDs,
+ LayoutNodes: layoutNodes,
+ LayoutEdges: layoutEdges,
+ }
+}
+
+func resolveOr(pos map[string]XY, id string, x, y float64) XY {
+ if prev, ok := pos[id]; ok {
+ return prev
+ }
+ p := XY{X: x, Y: y}
+ pos[id] = p
+ return p
+}
+
+func fontHighFor(dark bool) map[string]any {
+ if dark {
+ return fontHighLight
+ }
+ return fontHighDark
+}
+
+func colorBlue(dark bool) map[string]any {
+ if dark {
+ return colorLxmfMultiDark
+ }
+ return colorLxmfMultiLight
+}
+
+func ifaceImage(online bool) string {
+ if online {
+ return "/assets/images/network-visualiser/interface_connected.png"
+ }
+ return "/assets/images/network-visualiser/interface_disconnected.png"
+}
+
+func ifaceColor(online, dark bool) map[string]any {
+ if online {
+ bg := "#ecfdf5"
+ if dark {
+ bg = "#064e3b"
+ }
+ return nodeColor("#10b981", bg)
+ }
+ bg := "#fef2f2"
+ if dark {
+ bg = "#7f1d1d"
+ }
+ return nodeColor("#ef4444", bg)
+}
+
+func edgeDirect(dark bool) map[string]any {
+ if dark {
+ return edgeDirectDark
+ }
+ return edgeDirectLight
+}
+
+func edgeOffline(dark bool) map[string]any {
+ c := "#ef4444"
+ if dark {
+ c = "#f87171"
+ }
+ return map[string]any{"color": c, "opacity": 1.0}
+}
+
+func discoveredColor(connected, dark bool) map[string]any {
+ if connected {
+ bg := "#ecfeff"
+ if dark {
+ bg = "#164e63"
+ }
+ return nodeColor("#06b6d4", bg)
+ }
+ bg := "#f1f5f9"
+ if dark {
+ bg = "#1e293b"
+ }
+ return nodeColor("#64748b", bg)
+}

diff --git a/visualiser-wasm/internal/graph/full_test.go b/visualiser-wasm/internal/graph/full_test.go
new file mode 100644
index 00000000..91aec46b
--- /dev/null
+++ b/visualiser-wasm/internal/graph/full_test.go
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: 0BSD
+
+package graph_test
+
+import (
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/filter"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/graph"
+)
+
+func TestBuildFullGraphIncludesMeAndInterfaces(t *testing.T) {
+ h := 1.0
+ res := graph.BuildFullGraph(graph.FullRequest{
+ MeLabel: "Home",
+ MeTitle: "Local",
+ MeImage: "/logo.png",
+ Interfaces: []graph.InterfaceIn{
+ {Name: "eth0", Label: "eth0", Title: "eth0 online", Online: true},
+ },
+ PathTable: []filter.PathEntry{
+ {Hash: "aa", Interface: "eth0", Hops: &h},
+ },
+ Announces: map[string]graph.Announce{
+ "aa": {DestinationHash: "aa", Aspect: "lxmf.delivery", DisplayName: "Alice", LastSeen: "now"},
+ },
+ DarkMode: true,
+ LOD: "high",
+ })
+ if len(res.Nodes) < 3 {
+ t.Fatalf("expected me+iface+announce, got %d", len(res.Nodes))
+ }
+ if len(res.LayoutNodes) != len(res.Nodes) {
+ t.Fatalf("layout bodies mismatch")
+ }
+ if len(res.LayoutEdges) != len(res.Edges) {
+ t.Fatalf("layout springs mismatch")
+ }
+}

diff --git a/visualiser-wasm/internal/hashpos/hashpos.go b/visualiser-wasm/internal/hashpos/hashpos.go
new file mode 100644
index 00000000..5af64c31
--- /dev/null
+++ b/visualiser-wasm/internal/hashpos/hashpos.go
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: 0BSD
+
+// Package hashpos derives stable layout angles from node ids.
+package hashpos
+
+import "math"
+
+// fnv1a32 hashes s with FNV-1a without heap allocation.
+func fnv1a32(s string) uint32 {
+ const (
+ offset = 2166136261
+ prime = 16777619
+ )
+ h := uint32(offset)
+ for i := 0; i < len(s); i++ {
+ h ^= uint32(s[i])
+ h *= prime
+ }
+ return h
+}
+
+// fnv1a32Salt hashes id + NUL + salt with FNV-1a without heap allocation.
+func fnv1a32Salt(id, salt string) uint32 {
+ const (
+ offset = 2166136261
+ prime = 16777619
+ )
+ h := uint32(offset)
+ for i := 0; i < len(id); i++ {
+ h ^= uint32(id[i])
+ h *= prime
+ }
+ h ^= 0
+ h *= prime
+ for i := 0; i < len(salt); i++ {
+ h ^= uint32(salt[i])
+ h *= prime
+ }
+ return h
+}
+
+// Angle01 returns a deterministic angle in [0, 2*Pi) for id.
+func Angle01(id string) float64 {
+ u := fnv1a32(id)
+ return (float64(u%10000) / 10000.0) * 2 * math.Pi
+}
+
+// Dist01 returns a deterministic unit fraction in [0, 1) for id and salt.
+func Dist01(id, salt string) float64 {
+ u := fnv1a32Salt(id, salt)
+ return float64(u%10000) / 10000.0
+}
+
+// XY places a point at radius base+span*Dist01 around the origin.
+func XY(id string, base, span float64) (x, y float64) {
+ a := Angle01(id)
+ d := base + Dist01(id, "r")*span
+ return math.Cos(a) * d, math.Sin(a) * d
+}
+
+// Around places a point near parent using a deterministic offset.
+func Around(id string, px, py, base, span float64) (x, y float64) {
+ a := Angle01(id)
+ d := base + Dist01(id, "r")*span
+ return px + math.Cos(a)*d, py + math.Sin(a)*d
+}

diff --git a/visualiser-wasm/internal/hashpos/hashpos_test.go b/visualiser-wasm/internal/hashpos/hashpos_test.go
new file mode 100644
index 00000000..9e6a5fb3
--- /dev/null
+++ b/visualiser-wasm/internal/hashpos/hashpos_test.go
@@ -0,0 +1,127 @@
+// SPDX-License-Identifier: 0BSD
+
+package hashpos_test
+
+import (
+ "math"
+ "sync"
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/hashpos"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
+)
+
+func TestAngle01Stable(t *testing.T) {
+ a1 := hashpos.Angle01("abc")
+ a2 := hashpos.Angle01("abc")
+ if a1 != a2 {
+ t.Fatalf("unstable angle: %v vs %v", a1, a2)
+ }
+ if a1 < 0 || a1 >= 2*math.Pi {
+ t.Fatalf("angle out of range: %v", a1)
+ }
+}
+
+func TestDist01Range(t *testing.T) {
+ d := hashpos.Dist01("node", "r")
+ if d < 0 || d >= 1 {
+ t.Fatalf("dist out of range: %v", d)
+ }
+}
+
+func TestXYAroundFinite(t *testing.T) {
+ x, y := hashpos.XY("n1", 600, 200)
+ if math.IsNaN(x) || math.IsNaN(y) || math.IsInf(x, 0) || math.IsInf(y, 0) {
+ t.Fatalf("bad XY: %v %v", x, y)
+ }
+ ax, ay := hashpos.Around("n1", 10, 20, 150, 150)
+ if math.IsNaN(ax) || math.IsNaN(ay) {
+ t.Fatalf("bad Around: %v %v", ax, ay)
+ }
+}
+
+func TestHashposNoGoroutineLeak(t *testing.T) {
+ defer leaktest.Check(t)()
+ for i := 0; i < 1000; i++ {
+ _, _ = hashpos.XY("node", 100, 50)
+ _, _ = hashpos.Around("node", 1, 2, 10, 10)
+ }
+}
+
+func TestHashposRace(t *testing.T) {
+ var wg sync.WaitGroup
+ for i := 0; i < 32; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ id := "race-" + string(rune('a'+n%26))
+ for j := 0; j < 200; j++ {
+ _ = hashpos.Angle01(id)
+ _ = hashpos.Dist01(id, "r")
+ _, _ = hashpos.XY(id, 600, 200)
+ _, _ = hashpos.Around(id, 0, 0, 150, 150)
+ }
+ }(i)
+ }
+ wg.Wait()
+}
+
+func FuzzAngle01(f *testing.F) {
+ f.Add("")
+ f.Add("me")
+ f.Add("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ f.Fuzz(func(t *testing.T, id string) {
+ a := hashpos.Angle01(id)
+ if math.IsNaN(a) || math.IsInf(a, 0) {
+ t.Fatalf("bad angle %v for %q", a, id)
+ }
+ d := hashpos.Dist01(id, "r")
+ if d < 0 || d >= 1 || math.IsNaN(d) {
+ t.Fatalf("bad dist %v for %q", d, id)
+ }
+ x, y := hashpos.XY(id, 1, 1)
+ if math.IsNaN(x) || math.IsNaN(y) {
+ t.Fatalf("bad xy")
+ }
+ })
+}
+
+func BenchmarkAngle01(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = hashpos.Angle01("destinationhash0123456789abcdef")
+ }
+}
+
+func BenchmarkDist01(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = hashpos.Dist01("destinationhash0123456789abcdef", "r")
+ }
+}
+
+func BenchmarkXY(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _, _ = hashpos.XY("destinationhash0123456789abcdef", 600, 200)
+ }
+}
+
+func BenchmarkAround(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _, _ = hashpos.Around("destinationhash0123456789abcdef", 100, 200, 150, 150)
+ }
+}
+
+func TestHashposZeroAllocs(t *testing.T) {
+ allocs := testing.AllocsPerRun(1000, func() {
+ _ = hashpos.Angle01("destinationhash0123456789abcdef")
+ _ = hashpos.Dist01("destinationhash0123456789abcdef", "r")
+ _, _ = hashpos.XY("destinationhash0123456789abcdef", 600, 200)
+ _, _ = hashpos.Around("destinationhash0123456789abcdef", 0, 0, 150, 150)
+ })
+ if allocs != 0 {
+ t.Fatalf("expected 0 allocs, got %v", allocs)
+ }
+}

diff --git a/visualiser-wasm/internal/icon/dedupe.go b/visualiser-wasm/internal/icon/dedupe.go
new file mode 100644
index 00000000..2084f963
--- /dev/null
+++ b/visualiser-wasm/internal/icon/dedupe.go
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: 0BSD
+
+// Package icon collapses deferred icon paint work for the visualiser.
+package icon
+
+// QueueItem is one deferred custom-icon paint request.
+type QueueItem struct {
+ NodeID string `json:"nodeId"`
+ CacheKey string `json:"cacheKey"`
+ IconName string `json:"iconName"`
+ FG string `json:"fg"`
+ BG string `json:"bg"`
+ Size float64 `json:"size"`
+ Generation int `json:"generation"`
+}
+
+// DedupedBucket is one unique cache key with all node ids that share it.
+type DedupedBucket struct {
+ CacheKey string `json:"cacheKey"`
+ NodeIDs []string `json:"nodeIds"`
+ IconName string `json:"iconName"`
+ FG string `json:"fg"`
+ BG string `json:"bg"`
+ Size float64 `json:"size"`
+ Generation int `json:"generation"`
+}
+
+// DedupeQueueEntries collapses duplicate cacheKey entries into one paint job.
+func DedupeQueueEntries(queue []QueueItem) []DedupedBucket {
+ if len(queue) == 0 {
+ return nil
+ }
+ order := make([]string, 0, 16)
+ byKey := make(map[string]*DedupedBucket, 16)
+ seenNodes := make(map[string]map[string]struct{}, 16)
+
+ for i := range queue {
+ item := &queue[i]
+ if item.CacheKey == "" || item.NodeID == "" {
+ continue
+ }
+ bucket, ok := byKey[item.CacheKey]
+ if !ok {
+ bucket = &DedupedBucket{
+ CacheKey: item.CacheKey,
+ NodeIDs: make([]string, 0, 4),
+ IconName: item.IconName,
+ FG: item.FG,
+ BG: item.BG,
+ Size: item.Size,
+ Generation: item.Generation,
+ }
+ byKey[item.CacheKey] = bucket
+ seenNodes[item.CacheKey] = make(map[string]struct{}, 4)
+ order = append(order, item.CacheKey)
+ }
+ seen := seenNodes[item.CacheKey]
+ if _, dup := seen[item.NodeID]; dup {
+ continue
+ }
+ seen[item.NodeID] = struct{}{}
+ bucket.NodeIDs = append(bucket.NodeIDs, item.NodeID)
+ }
+
+ out := make([]DedupedBucket, 0, len(order))
+ for _, key := range order {
+ out = append(out, *byKey[key])
+ }
+ return out
+}

diff --git a/visualiser-wasm/internal/icon/dedupe_test.go b/visualiser-wasm/internal/icon/dedupe_test.go
new file mode 100644
index 00000000..8e923d07
--- /dev/null
+++ b/visualiser-wasm/internal/icon/dedupe_test.go
@@ -0,0 +1,114 @@
+// SPDX-License-Identifier: 0BSD
+
+package icon_test
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/icon"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
+)
+
+func TestDedupeQueueEntries(t *testing.T) {
+ queue := []icon.QueueItem{
+ {NodeID: "n1", CacheKey: "k1", IconName: "a", FG: "#000", BG: "#fff", Size: 64, Generation: 1},
+ {NodeID: "n2", CacheKey: "k1", IconName: "a", FG: "#000", BG: "#fff", Size: 64, Generation: 1},
+ {NodeID: "n3", CacheKey: "k2", IconName: "b", FG: "#111", BG: "#eee", Size: 64, Generation: 1},
+ {NodeID: "n1", CacheKey: "k1", IconName: "a", FG: "#000", BG: "#fff", Size: 64, Generation: 1},
+ }
+ out := icon.DedupeQueueEntries(queue)
+ if len(out) != 2 {
+ t.Fatalf("expected 2 buckets, got %d", len(out))
+ }
+ if len(out[0].NodeIDs) != 2 || out[0].NodeIDs[0] != "n1" || out[0].NodeIDs[1] != "n2" {
+ t.Fatalf("unexpected k1 nodes: %#v", out[0].NodeIDs)
+ }
+ if icon.DedupeQueueEntries(nil) == nil {
+ // nil or empty both acceptable if empty result length is 0
+ }
+ if len(icon.DedupeQueueEntries(nil)) != 0 {
+ t.Fatal("empty queue should yield empty result")
+ }
+}
+
+func TestIconNoGoroutineLeak(t *testing.T) {
+ defer leaktest.Check(t)()
+ queue := make([]icon.QueueItem, 200)
+ for i := range queue {
+ queue[i] = icon.QueueItem{
+ NodeID: "n" + string(rune('0'+i%10)),
+ CacheKey: "k" + string(rune('a'+i%5)),
+ IconName: "ico",
+ FG: "#000",
+ BG: "#fff",
+ Size: 64,
+ }
+ }
+ for i := 0; i < 100; i++ {
+ _ = icon.DedupeQueueEntries(queue)
+ }
+}
+
+func TestIconRace(t *testing.T) {
+ queue := make([]icon.QueueItem, 300)
+ for i := range queue {
+ queue[i] = icon.QueueItem{
+ NodeID: "n" + string(rune('0'+i%10)),
+ CacheKey: "k" + string(rune('a'+i%8)),
+ IconName: "ico",
+ Size: 64,
+ }
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 50; j++ {
+ _ = icon.DedupeQueueEntries(queue)
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func FuzzDedupeQueueEntries(f *testing.F) {
+ f.Add("n1", "k1", "icon", "#000", "#fff")
+ f.Add("", "k", "i", "", "")
+ f.Fuzz(func(t *testing.T, nodeID, cacheKey, name, fg, bg string) {
+ queue := []icon.QueueItem{
+ {NodeID: nodeID, CacheKey: cacheKey, IconName: name, FG: fg, BG: bg, Size: 64},
+ {NodeID: nodeID, CacheKey: cacheKey, IconName: name, FG: fg, BG: bg, Size: 64},
+ }
+ out := icon.DedupeQueueEntries(queue)
+ if cacheKey == "" || nodeID == "" {
+ if len(out) != 0 {
+ t.Fatalf("expected skip, got %#v", out)
+ }
+ return
+ }
+ if len(out) != 1 || len(out[0].NodeIDs) != 1 {
+ t.Fatalf("unexpected %#v", out)
+ }
+ })
+}
+
+func BenchmarkDedupeQueueEntries(b *testing.B) {
+ queue := make([]icon.QueueItem, 1000)
+ for i := range queue {
+ queue[i] = icon.QueueItem{
+ NodeID: "n" + string(rune('0'+i%20)),
+ CacheKey: "k" + string(rune('a'+i%10)),
+ IconName: "ico",
+ FG: "#000",
+ BG: "#fff",
+ Size: 64,
+ }
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = icon.DedupeQueueEntries(queue)
+ }
+}

diff --git a/visualiser-wasm/internal/layout/force.go b/visualiser-wasm/internal/layout/force.go
new file mode 100644
index 00000000..5a807b2d
--- /dev/null
+++ b/visualiser-wasm/internal/layout/force.go
@@ -0,0 +1,247 @@
+// SPDX-License-Identifier: 0BSD
+
+// Package layout settles node positions with a fast force simulation.
+// Runs in WASM so vis-network can keep its JS physics solver off
+// (that solver is the main FPS bottleneck for large graphs).
+package layout
+
+import (
+ "math"
+)
+
+// Node is one body in the layout simulation.
+type Node struct {
+ ID string `json:"id"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Mass float64 `json:"mass"`
+ Fixed bool `json:"fixed"`
+}
+
+// Edge is a spring between two node ids.
+type Edge struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Length float64 `json:"length"`
+}
+
+// Request configures a layout settle pass.
+type Request struct {
+ Nodes []Node `json:"nodes"`
+ Edges []Edge `json:"edges"`
+ Iterations int `json:"iterations"`
+ Gravity float64 `json:"gravity"`
+ Repulsion float64 `json:"repulsion"`
+ SpringK float64 `json:"spring_k"`
+ Damping float64 `json:"damping"`
+ MaxSpeed float64 `json:"max_speed"`
+}
+
+// Result is settled positions keyed by node id.
+type Result struct {
+ Positions map[string]XY `json:"positions"`
+ Iterations int `json:"iterations"`
+}
+
+// XY is a 2D point.
+type XY struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type body struct {
+ id string
+ x, y float64
+ vx, vy float64
+ mass float64
+ fixed bool
+}
+
+// Settle runs a damped spring + grid-repulsion layout in-place.
+func Settle(req Request) Result {
+ n := len(req.Nodes)
+ out := Result{Positions: make(map[string]XY, n)}
+ if n == 0 {
+ return out
+ }
+
+ iters := req.Iterations
+ if iters <= 0 {
+ iters = pickIterations(n)
+ }
+ if iters > 400 {
+ iters = 400
+ }
+
+ gravity := req.Gravity
+ if gravity == 0 {
+ gravity = 0.012
+ }
+ repulsion := req.Repulsion
+ if repulsion == 0 {
+ repulsion = 1800
+ }
+ springK := req.SpringK
+ if springK == 0 {
+ springK = 0.045
+ }
+ damping := req.Damping
+ if damping == 0 {
+ damping = 0.82
+ }
+ maxSpeed := req.MaxSpeed
+ if maxSpeed == 0 {
+ maxSpeed = 40
+ }
+
+ bodies := make([]body, n)
+ index := make(map[string]int, n)
+ for i := range req.Nodes {
+ nd := &req.Nodes[i]
+ mass := nd.Mass
+ if mass <= 0 {
+ mass = 1
+ }
+ bodies[i] = body{
+ id: nd.ID,
+ x: nd.X,
+ y: nd.Y,
+ mass: mass,
+ fixed: nd.Fixed || nd.ID == "me",
+ }
+ index[nd.ID] = i
+ }
+
+ type spring struct {
+ a, b int
+ len float64
+ }
+ springs := make([]spring, 0, len(req.Edges))
+ for i := range req.Edges {
+ e := &req.Edges[i]
+ ai, okA := index[e.From]
+ bi, okB := index[e.To]
+ if !okA || !okB || ai == bi {
+ continue
+ }
+ length := e.Length
+ if length <= 0 {
+ length = 180
+ }
+ springs = append(springs, spring{a: ai, b: bi, len: length})
+ }
+
+ cellSize := 160.0
+ for step := 0; step < iters; step++ {
+ fx := make([]float64, n)
+ fy := make([]float64, n)
+
+ // Weak pull toward origin keeps the mesh centred.
+ for i := range bodies {
+ if bodies[i].fixed {
+ continue
+ }
+ fx[i] -= bodies[i].x * gravity * bodies[i].mass
+ fy[i] -= bodies[i].y * gravity * bodies[i].mass
+ }
+
+ // Grid-bucketed repulsion (near O(n) average).
+ buckets := make(map[[2]int][]int, n/2+1)
+ for i := range bodies {
+ cx := int(math.Floor(bodies[i].x / cellSize))
+ cy := int(math.Floor(bodies[i].y / cellSize))
+ key := [2]int{cx, cy}
+ buckets[key] = append(buckets[key], i)
+ }
+ for i := range bodies {
+ if bodies[i].fixed {
+ continue
+ }
+ cx := int(math.Floor(bodies[i].x / cellSize))
+ cy := int(math.Floor(bodies[i].y / cellSize))
+ for dx := -1; dx <= 1; dx++ {
+ for dy := -1; dy <= 1; dy++ {
+ list := buckets[[2]int{cx + dx, cy + dy}]
+ for _, j := range list {
+ if j <= i {
+ continue
+ }
+ dxp := bodies[i].x - bodies[j].x
+ dyp := bodies[i].y - bodies[j].y
+ dist2 := dxp*dxp + dyp*dyp + 0.01
+ inv := 1.0 / math.Sqrt(dist2)
+ force := repulsion * bodies[i].mass * bodies[j].mass * inv * inv
+ fx[i] += dxp * inv * force
+ fy[i] += dyp * inv * force
+ if !bodies[j].fixed {
+ fx[j] -= dxp * inv * force
+ fy[j] -= dyp * inv * force
+ }
+ }
+ }
+ }
+ }
+
+ // Springs
+ for _, s := range springs {
+ a := &bodies[s.a]
+ b := &bodies[s.b]
+ dxp := b.x - a.x
+ dyp := b.y - a.y
+ dist := math.Sqrt(dxp*dxp + dyp*dyp)
+ if dist < 0.01 {
+ dist = 0.01
+ }
+ delta := dist - s.len
+ force := springK * delta
+ ux := dxp / dist
+ uy := dyp / dist
+ if !a.fixed {
+ fx[s.a] += ux * force
+ fy[s.a] += uy * force
+ }
+ if !b.fixed {
+ fx[s.b] -= ux * force
+ fy[s.b] -= uy * force
+ }
+ }
+
+ // Integrate
+ for i := range bodies {
+ if bodies[i].fixed {
+ bodies[i].vx = 0
+ bodies[i].vy = 0
+ continue
+ }
+ bodies[i].vx = (bodies[i].vx + fx[i]/bodies[i].mass) * damping
+ bodies[i].vy = (bodies[i].vy + fy[i]/bodies[i].mass) * damping
+ speed := math.Hypot(bodies[i].vx, bodies[i].vy)
+ if speed > maxSpeed {
+ scale := maxSpeed / speed
+ bodies[i].vx *= scale
+ bodies[i].vy *= scale
+ }
+ bodies[i].x += bodies[i].vx
+ bodies[i].y += bodies[i].vy
+ }
+ }
+
+ for i := range bodies {
+ out.Positions[bodies[i].id] = XY{X: bodies[i].x, Y: bodies[i].y}
+ }
+ out.Iterations = iters
+ return out
+}
+
+func pickIterations(n int) int {
+ if n >= 1500 {
+ return 45
+ }
+ if n >= 600 {
+ return 70
+ }
+ if n >= 200 {
+ return 100
+ }
+ return 140
+}

diff --git a/visualiser-wasm/internal/layout/force_test.go b/visualiser-wasm/internal/layout/force_test.go
new file mode 100644
index 00000000..98d11faa
--- /dev/null
+++ b/visualiser-wasm/internal/layout/force_test.go
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: 0BSD
+
+package layout_test
+
+import (
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/layout"
+)
+
+func TestSettleMovesUnfixedNodes(t *testing.T) {
+ res := layout.Settle(layout.Request{
+ Nodes: []layout.Node{
+ {ID: "me", X: 0, Y: 0, Mass: 4, Fixed: true},
+ {ID: "a", X: 10, Y: 0, Mass: 1},
+ {ID: "b", X: -10, Y: 0, Mass: 1},
+ },
+ Edges: []layout.Edge{
+ {From: "me", To: "a", Length: 200},
+ {From: "me", To: "b", Length: 200},
+ },
+ Iterations: 80,
+ })
+ if len(res.Positions) != 3 {
+ t.Fatalf("expected 3 positions, got %d", len(res.Positions))
+ }
+ if res.Positions["me"].X != 0 || res.Positions["me"].Y != 0 {
+ t.Fatalf("me should stay fixed: %#v", res.Positions["me"])
+ }
+ // Springs should push a/b outward from the tiny start distance.
+ if abs(res.Positions["a"].X) < 20 {
+ t.Fatalf("expected a to move outward, got %#v", res.Positions["a"])
+ }
+}
+
+func TestSettleEmpty(t *testing.T) {
+ res := layout.Settle(layout.Request{})
+ if len(res.Positions) != 0 {
+ t.Fatal("expected empty")
+ }
+}
+
+func BenchmarkSettle500(b *testing.B) {
+ nodes := make([]layout.Node, 500)
+ edges := make([]layout.Edge, 0, 500)
+ nodes[0] = layout.Node{ID: "me", Fixed: true, Mass: 4}
+ for i := 1; i < 500; i++ {
+ id := "n" + itoa(i)
+ nodes[i] = layout.Node{ID: id, X: float64(i%50) * 10, Y: float64(i/50) * 10, Mass: 1}
+ edges = append(edges, layout.Edge{From: "me", To: id, Length: 200})
+ }
+ req := layout.Request{Nodes: nodes, Edges: edges, Iterations: 60}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = layout.Settle(req)
+ }
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var buf [16]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(buf[i:])
+}
+
+func abs(v float64) float64 {
+ if v < 0 {
+ return -v
+ }
+ return v
+}

diff --git a/visualiser-wasm/internal/leaktest/leaktest.go b/visualiser-wasm/internal/leaktest/leaktest.go
new file mode 100644
index 00000000..27b33e6d
--- /dev/null
+++ b/visualiser-wasm/internal/leaktest/leaktest.go
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: 0BSD
+
+// Package leaktest provides lightweight goroutine leak checks without deps.
+package leaktest
+
+import (
+ "runtime"
+ "testing"
+ "time"
+)
+
+// Check returns a cleanup that fails the test if goroutines leaked.
+func Check(t testing.TB) func() {
+ t.Helper()
+ runtime.GC()
+ time.Sleep(5 * time.Millisecond)
+ before := runtime.NumGoroutine()
+ return func() {
+ t.Helper()
+ deadline := time.Now().Add(500 * time.Millisecond)
+ var after int
+ for {
+ runtime.GC()
+ after = runtime.NumGoroutine()
+ if after <= before {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("goroutine leak: before=%d after=%d", before, after)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ }
+}

diff --git a/visualiser-wasm/internal/lod/lod.go b/visualiser-wasm/internal/lod/lod.go
new file mode 100644
index 00000000..e88487ce
--- /dev/null
+++ b/visualiser-wasm/internal/lod/lod.go
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: 0BSD
+
+// Package lod computes level-of-detail property patches for vis-network nodes.
+package lod
+
+// NodeIn is the minimal node state needed to compute an LOD patch.
+type NodeIn struct {
+ ID string `json:"id"`
+ Group string `json:"group"`
+ Shape string `json:"shape"`
+ Size float64 `json:"size"`
+ OriginalShape string `json:"_originalShape"`
+ OriginalSize float64 `json:"_originalSize"`
+ Color map[string]any `json:"color"`
+ Font *FontIn `json:"font"`
+}
+
+// FontIn carries the current font size when present.
+type FontIn struct {
+ Size *float64 `json:"size"`
+}
+
+// Update is a sparse vis-network node patch for one LOD change.
+type Update struct {
+ ID string `json:"id"`
+ Shape string `json:"shape,omitempty"`
+ Size *float64 `json:"size,omitempty"`
+ Font map[string]any `json:"font,omitempty"`
+ Color map[string]any `json:"color,omitempty"`
+}
+
+var (
+ fontSize0 = map[string]any{"size": 0.0}
+ fontHighLight11 = map[string]any{"size": 11.0, "color": "#ffffff"}
+ fontHighDark11 = map[string]any{"size": 11.0, "color": "#000000"}
+ fontHighLight16 = map[string]any{"size": 16.0, "color": "#ffffff"}
+ fontHighDark16 = map[string]any{"size": 16.0, "color": "#000000"}
+ colorBlueLight = nodeColor("#3b82f6", "#eff6ff")
+ colorBlueDark = nodeColor("#3b82f6", "#1e40af")
+ size10 = 10.0
+ size15 = 15.0
+ size25 = 25.0
+ size50 = 50.0
+)
+
+// LevelFromScale maps a vis-network camera scale to low/medium/high.
+func LevelFromScale(scale float64) string {
+ if scale < 0.2 {
+ return "low"
+ }
+ if scale < 0.5 {
+ return "medium"
+ }
+ return "high"
+}
+
+// ComputeUpdates returns only nodes whose LOD props actually change.
+func ComputeUpdates(nodes []NodeIn, level string, darkMode bool) []Update {
+ if len(nodes) == 0 {
+ return nil
+ }
+ blue := colorBlueLight
+ if darkMode {
+ blue = colorBlueDark
+ }
+ fontMe := fontHighDark16
+ fontPeer := fontHighDark11
+ if darkMode {
+ fontMe = fontHighLight16
+ fontPeer = fontHighLight11
+ }
+
+ out := make([]Update, 0, len(nodes)/4+1)
+ for i := range nodes {
+ n := &nodes[i]
+ next := propsFor(n, level, fontMe, fontPeer, blue)
+ if !changed(n, next) {
+ continue
+ }
+ out = append(out, next)
+ }
+ return out
+}
+
+func propsFor(n *NodeIn, level string, fontMe, fontPeer, blue map[string]any) Update {
+ u := Update{ID: n.ID}
+ switch level {
+ case "low":
+ if n.ID == "me" {
+ u.Size = &size15
+ } else {
+ u.Size = &size10
+ }
+ u.Shape = "dot"
+ u.Font = fontSize0
+ if n.Group == "interface" && n.Color != nil {
+ u.Color = n.Color
+ } else {
+ u.Color = blue
+ }
+ case "medium":
+ shape := n.OriginalShape
+ if shape == "" {
+ shape = "circularImage"
+ }
+ u.Shape = shape
+ u.Size = originalSizePtr(n)
+ u.Font = fontSize0
+ default:
+ shape := n.OriginalShape
+ if shape == "" {
+ shape = "circularImage"
+ }
+ u.Shape = shape
+ u.Size = originalSizePtr(n)
+ if n.ID == "me" {
+ u.Font = fontMe
+ } else {
+ u.Font = fontPeer
+ }
+ }
+ return u
+}
+
+func originalSizePtr(n *NodeIn) *float64 {
+ if n.OriginalSize != 0 {
+ // Return a pointer into a stable set of common sizes when possible.
+ switch n.OriginalSize {
+ case 10:
+ return &size10
+ case 15:
+ return &size15
+ case 25:
+ return &size25
+ case 50:
+ return &size50
+ }
+ v := n.OriginalSize
+ return &v
+ }
+ if n.ID == "me" {
+ return &size50
+ }
+ return &size25
+}
+
+func changed(n *NodeIn, next Update) bool {
+ if next.Shape != "" && next.Shape != n.Shape {
+ return true
+ }
+ if next.Size != nil && *next.Size != n.Size {
+ return true
+ }
+ if next.Font != nil {
+ ns, ok := next.Font["size"]
+ if ok {
+ var cur float64
+ if n.Font != nil && n.Font.Size != nil {
+ cur = *n.Font.Size
+ }
+ switch v := ns.(type) {
+ case float64:
+ if v != cur {
+ return true
+ }
+ case int:
+ if float64(v) != cur {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func nodeColor(border, background string) map[string]any {
+ return map[string]any{
+ "border": border,
+ "background": background,
+ "highlight": map[string]any{
+ "border": border,
+ "background": background,
+ },
+ "hover": map[string]any{
+ "border": border,
+ "background": background,
+ },
+ }
+}

diff --git a/visualiser-wasm/internal/lod/lod_test.go b/visualiser-wasm/internal/lod/lod_test.go
new file mode 100644
index 00000000..41f92f0f
--- /dev/null
+++ b/visualiser-wasm/internal/lod/lod_test.go
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: 0BSD
+
+package lod_test
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/leaktest"
+ "github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/lod"
+)
+
+func TestLevelFromScale(t *testing.T) {
+ if lod.LevelFromScale(0.1) != "low" {
+ t.Fatal("expected low")
+ }
+ if lod.LevelFromScale(0.3) != "medium" {
+ t.Fatal("expected medium")
+ }
+ if lod.LevelFromScale(0.8) != "high" {
+ t.Fatal("expected high")
+ }
+}
+
+func TestComputeUpdatesLow(t *testing.T) {
+ orig := 25.0
+ nodes := []lod.NodeIn{
+ {ID: "n1", Shape: "circularImage", Size: 25, OriginalShape: "circularImage", OriginalSize: orig},
+ }
+ out := lod.ComputeUpdates(nodes, "low", false)
+ if len(out) != 1 || out[0].Shape != "dot" {
+ t.Fatalf("unexpected: %#v", out)
+ }
+ if out[0].Size == nil || *out[0].Size != 10 {
+ t.Fatalf("expected size 10, got %#v", out[0].Size)
+ }
+}
+
+func TestComputeUpdatesNoChange(t *testing.T) {
+ sz := 10.0
+ fs := 0.0
+ nodes := []lod.NodeIn{
+ {ID: "n1", Shape: "dot", Size: 10, OriginalShape: "circularImage", OriginalSize: 25, Font: &lod.FontIn{Size: &fs}},
+ }
+ out := lod.ComputeUpdates(nodes, "low", false)
+ _ = sz
+ if len(out) != 0 {
+ t.Fatalf("expected no updates, got %#v", out)
+ }
+}
+
+func TestLODNoGoroutineLeak(t *testing.T) {
+ defer leaktest.Check(t)()
+ nodes := make([]lod.NodeIn, 500)
+ for i := range nodes {
+ nodes[i] = lod.NodeIn{
+ ID: "n" + string(rune('a'+i%26)),
+ Shape: "circularImage",
+ Size: 25,
+ OriginalShape: "circularImage",
+ OriginalSize: 25,
+ }
+ }
+ for i := 0; i < 100; i++ {
+ _ = lod.ComputeUpdates(nodes, "low", true)
+ _ = lod.LevelFromScale(0.25)
+ }
+}
+
+func TestLODRace(t *testing.T) {
+ nodes := make([]lod.NodeIn, 1000)
+ for i := range nodes {
+ nodes[i] = lod.NodeIn{
+ ID: "n" + string(rune('0'+i%10)),
+ Shape: "circularImage",
+ Size: 25,
+ OriginalShape: "circularImage",
+ OriginalSize: 25,
+ }
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func(level string) {
+ defer wg.Done()
+ for j := 0; j < 50; j++ {
+ _ = lod.ComputeUpdates(nodes, level, j%2 == 0)
+ _ = lod.LevelFromScale(float64(j) / 100)
+ }
+ }([]string{"low", "medium", "high"}[i%3])
+ }
+ wg.Wait()
+}
+
+func FuzzLevelFromScale(f *testing.F) {
+ f.Add(0.0)
+ f.Add(0.2)
+ f.Add(0.5)
+ f.Add(1.0)
+ f.Fuzz(func(t *testing.T, scale float64) {
+ level := lod.LevelFromScale(scale)
+ switch level {
+ case "low", "medium", "high":
+ default:
+ t.Fatalf("bad level %q", level)
+ }
+ })
+}
+
+func FuzzComputeUpdates(f *testing.F) {
+ f.Add("n1", "circularImage", 25.0, "low", true)
+ f.Add("me", "dot", 15.0, "high", false)
+ f.Fuzz(func(t *testing.T, id, shape string, size float64, level string, dark bool) {
+ nodes := []lod.NodeIn{{
+ ID: id,
+ Shape: shape,
+ Size: size,
+ OriginalShape: "circularImage",
+ OriginalSize: 25,
+ }}
+ _ = lod.ComputeUpdates(nodes, level, dark)
+ })
+}
+
+func BenchmarkComputeUpdatesLow(b *testing.B) {
+ nodes := make([]lod.NodeIn, 2000)
+ for i := range nodes {
+ nodes[i] = lod.NodeIn{
+ ID: "n" + string(rune('a'+i%26)),
+ Shape: "circularImage",
+ Size: 25,
+ OriginalShape: "circularImage",
+ OriginalSize: 25,
+ }
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = lod.ComputeUpdates(nodes, "low", false)
+ }
+}
+
+func BenchmarkLevelFromScale(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = lod.LevelFromScale(0.33)
+ }
+}
+
+func TestLevelFromScaleZeroAllocs(t *testing.T) {
+ allocs := testing.AllocsPerRun(1000, func() {
+ _ = lod.LevelFromScale(0.33)
+ })
+ if allocs != 0 {
+ t.Fatalf("expected 0 allocs, got %v", allocs)
+ }
+}

diff --git a/vite.config.js b/vite.config.js
index 8c7d3436..72417030 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -42,9 +42,42 @@ const backendUsesHttps = !envBool(process.env.MESHCHAT_NO_HTTPS);
const e2eBackendOrigin = backendUsesHttps
? `https://127.0.0.1:${e2eBackendPort}`
: `http://127.0.0.1:${e2eBackendPort}`;
-const e2eBackendWs = backendUsesHttps ? `wss://127.0.0.1:${e2eBackendPort}` : `ws://127.0.0.1:${e2eBackendPort}`;
+// http-proxy expects an http(s) target for WS upgrades (ws: true). Using wss://
+// here has caused noisy write EPIPE / reconnect loops under Vite 8.
const backendProxyTls = backendUsesHttps ? { secure: false } : {};
+/**
+ * Attach quiet handlers for expected proxy disconnects (browser refresh,
+ * client reconnect, peer closed before proxy flush).
+ * @param {import('http-proxy').Server} proxy
+ */
+function configureQuietProxyErrors(proxy) {
+ proxy.on("error", (err, _req, res) => {
+ const code = err && err.code;
+ if (code === "EPIPE" || code === "ECONNRESET" || code === "ECONNREFUSED") {
+ if (res && !res.headersSent && typeof res.writeHead === "function") {
+ try {
+ res.writeHead(502);
+ res.end("Bad gateway");
+ } catch {
+ /* already closed */
+ }
+ }
+ return;
+ }
+ console.error("[vite] proxy error:", err);
+ });
+ proxy.on("proxyReqWs", (_proxyReq, _req, socket) => {
+ socket.on("error", (err) => {
+ const code = err && err.code;
+ if (code === "EPIPE" || code === "ECONNRESET") {
+ return;
+ }
+ console.error("[vite] ws proxy socket error:", err);
+ });
+ });
+}
+
const appBuildTimeIso = new Date().toISOString();
function isMicronWasmBundledResolved() {
@@ -61,7 +94,22 @@ function isMicronWasmBundledResolved() {
}
}
+function isVisualiserWasmBundledResolved() {
+ const wasmDir = path.join(__dirname, "meshchatx", "src", "frontend", "public", "vendor", "visualiser-wasm");
+ const wasmFile = path.join(wasmDir, "visualiser.wasm");
+ const execFile = path.join(wasmDir, "wasm_exec.js");
+ try {
+ if (!fs.existsSync(wasmFile) || !fs.existsSync(execFile)) {
+ return false;
+ }
+ return fs.statSync(wasmFile).size >= 8192 && fs.statSync(execFile).size >= 1024;
+ } catch {
+ return false;
+ }
+}
+
const micronWasmBundled = isMicronWasmBundledResolved();
+const visualiserWasmBundled = isVisualiserWasmBundledResolved();
function loadMicronWasmIntegrity() {
if (!micronWasmBundled) return null;
@@ -84,15 +132,39 @@ function loadMicronWasmIntegrity() {
}
}
+function loadVisualiserWasmIntegrity() {
+ if (!visualiserWasmBundled) return null;
+ const integrityPath = path.join(
+ __dirname,
+ "meshchatx",
+ "src",
+ "frontend",
+ "public",
+ "vendor",
+ "visualiser-wasm",
+ "integrity.json"
+ );
+ try {
+ return JSON.parse(fs.readFileSync(integrityPath, "utf-8"));
+ } catch {
+ console.warn("vite: could not load visualiser-wasm integrity.json");
+ return null;
+ }
+}
+
const micronWasmIntegrity = loadMicronWasmIntegrity();
+const visualiserWasmIntegrity = loadVisualiserWasmIntegrity();
export default defineConfig({
define: {
__APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso),
"import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"),
"import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG),
+ "import.meta.env.VITE_VISUALISER_WASM_BUNDLED": JSON.stringify(visualiserWasmBundled ? "true" : "false"),
__MICRON_WASM_SRI_WASM__: JSON.stringify(micronWasmIntegrity?.wasm || ""),
__MICRON_WASM_SRI_EXEC__: JSON.stringify(micronWasmIntegrity?.wasmExec || ""),
+ __VISUALISER_WASM_SRI_WASM__: JSON.stringify(visualiserWasmIntegrity?.wasm || ""),
+ __VISUALISER_WASM_SRI_EXEC__: JSON.stringify(visualiserWasmIntegrity?.wasmExec || ""),
},
plugins: [
tailwindcss(),
@@ -109,11 +181,39 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
- "/api": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
- "/ws": { target: e2eBackendWs, ws: true, ...backendProxyTls },
- "/ws/telephone/audio": { target: e2eBackendWs, ws: true, ...backendProxyTls },
- "/reticulum-docs": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
- "/meshchatx-docs": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
+ "/api": {
+ target: e2eBackendOrigin,
+ changeOrigin: true,
+ configure: configureQuietProxyErrors,
+ ...backendProxyTls,
+ },
+ // More specific WS path before the /ws prefix match.
+ "/ws/telephone/audio": {
+ target: e2eBackendOrigin,
+ ws: true,
+ changeOrigin: true,
+ configure: configureQuietProxyErrors,
+ ...backendProxyTls,
+ },
+ "/ws": {
+ target: e2eBackendOrigin,
+ ws: true,
+ changeOrigin: true,
+ configure: configureQuietProxyErrors,
+ ...backendProxyTls,
+ },
+ "/reticulum-docs": {
+ target: e2eBackendOrigin,
+ changeOrigin: true,
+ configure: configureQuietProxyErrors,
+ ...backendProxyTls,
+ },
+ "/meshchatx-docs": {
+ target: e2eBackendOrigin,
+ changeOrigin: true,
+ configure: configureQuietProxyErrors,
+ ...backendProxyTls,
+ },
},
},


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────